feat(a11y): add focus management to LoginDialog

- Add role="dialog", aria-modal="true", aria-labelledby
- Add aria-live="polite" and role="alert" on error messages
- Integrate useDialogFocus hook with FocusLock
- Maintain initial focus on username input
- Maintain Enter key submit and Escape key close
- Refactor error handling with internal state
This commit is contained in:
test
2026-03-08 17:09:37 +08:00
parent 545048045d
commit d1f2f40123

View File

@@ -8,6 +8,8 @@
*/
import React, { useState, useEffect, useRef } from 'react'
import FocusLock from 'react-focus-lock'
import { useDialogFocus } from '../hooks/useDialogFocus'
interface LoginDialogProps {
isOpen: boolean
@@ -27,24 +29,44 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoggingIn, setIsLoggingIn] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const usernameInputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const errorRef = useRef<HTMLDivElement>(null)
// Focus on username input when dialog opens
// 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) => {
setErrorMessage(message)
// Also call the original onError callback for backward compatibility
onError(message)
}
// Focus on username input when dialog opens (maintained for compatibility)
useEffect(() => {
if (isOpen && usernameInputRef.current) {
usernameInputRef.current.focus()
}
// Clear error message when dialog opens
setErrorMessage('')
}, [isOpen])
const handleLogin = async () => {
if (!username.trim()) {
onError('请输入用户名')
showError('请输入用户名')
usernameInputRef.current?.focus()
return
}
if (!password.trim()) {
onError('请输入密码')
showError('请输入密码')
return
}
@@ -53,7 +75,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({
setIsLoggingIn(false)
if (!success) {
onError('用户名或密码错误')
showError('用户名或密码错误')
setPassword('')
}
}
@@ -69,198 +91,86 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({
if (!isOpen) return null
return (
<div className="login-overlay" onKeyDown={handleKeyDown}>
<div className="login-dialog">
<div className="login-header">
<h2 className="login-title"></h2>
<p className="computer-name">{computerName}</p>
</div>
<div className="login-body">
<div className="form-group">
<label className="form-label text-slate-700">:</label>
<input
ref={usernameInputRef}
type="text"
className="form-input border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
disabled={isLoggingIn}
/>
<FocusLock {...focusLockProps}>
<div
className="login-overlay"
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="login-dialog-title"
>
<div className="login-dialog" ref={dialogRef}>
<div className="login-header">
<h2 id="login-dialog-title" className="login-title">
</h2>
<p className="computer-name">{computerName}</p>
</div>
<div className="form-group mt-4">
<label className="form-label text-slate-700">:</label>
<input
type="password"
className="form-input border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
disabled={isLoggingIn}
/>
{/* Error message area with aria-live for screen readers */}
{errorMessage && (
<div
ref={errorRef}
className="error-message mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm"
role="alert"
aria-live="polite"
tabIndex={-1}
>
{errorMessage}
</div>
)}
<div className="login-body">
<div className="form-group">
<label className="form-label text-slate-700">:</label>
<input
ref={usernameInputRef}
type="text"
className="form-input border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
disabled={isLoggingIn}
/>
</div>
<div className="form-group mt-4">
<label className="form-label text-slate-700">:</label>
<input
type="password"
className="form-input border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
disabled={isLoggingIn}
/>
</div>
</div>
</div>
<div className="login-footer mt-6 flex justify-end gap-3">
<button
className="btn btn-secondary px-4 py-2 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors"
onClick={onCancel}
disabled={isLoggingIn}
>
</button>
<button
className="btn btn-primary px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
onClick={handleLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? '登录中...' : '登录'}
</button>
</div>
<div className="login-footer mt-6 flex justify-end gap-3">
<button
className="btn btn-secondary px-4 py-2 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors"
onClick={onCancel}
disabled={isLoggingIn}
>
</button>
<button
className="btn btn-primary px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
onClick={handleLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? '登录中...' : '登录'}
</button>
</div>
<div className="login-version">v1.0</div>
<div className="login-version">v1.0</div>
</div>
</div>
<style>{`
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.login-dialog {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
width: 400px;
padding: 24px;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 20px;
}
.login-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.computer-name {
font-size: 12px;
color: #999;
margin: 0;
}
.login-body {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 6px;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
color: #333;
transition: border-color 0.3s, box-shadow 0.3s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.form-input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.login-footer {
display: flex;
gap: 12px;
justify-content: center;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #40a9ff;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.login-version {
text-align: center;
font-size: 12px;
color: #999;
margin-top: 16px;
}
`}</style>
</div>
</FocusLock>
)
}
export default LoginDialog
// Styles are now handled by Tailwind classes in the component