fix: enhance accessibility by adding aria-labels and data-testid attributes across various components

This commit is contained in:
GitHub Actions
2026-02-15 20:53:03 +00:00
parent 43c6317f82
commit ff8851bb7f
9 changed files with 74 additions and 38 deletions

View File

@@ -936,7 +936,12 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
setFormData(prev => ({ ...prev, websocket_support: needsWebsockets || prev.websocket_support }))
}}
>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Application Preset">
<SelectTrigger
id="application-preset"
data-testid="application-preset"
className="w-full bg-gray-900 border-gray-700 text-white"
aria-label="Application Preset"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1279,6 +1284,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
type="button"
onClick={onCancel}
disabled={loading}
data-testid="proxy-host-cancel"
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
@@ -1288,6 +1294,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
type="button"
onClick={handleTestConnection}
disabled={loading || testStatus === 'testing' || !formData.forward_host || !formData.forward_port}
data-testid="proxy-host-test-connection"
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50 ${
testStatus === 'success' ? 'bg-green-600 hover:bg-green-500 text-white' :
testStatus === 'error' ? 'bg-red-600 hover:bg-red-500 text-white' :
@@ -1304,6 +1311,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<button
type="submit"
disabled={loading}
data-testid="proxy-host-save"
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save'}

View File

@@ -51,7 +51,7 @@ export default function Certificates() {
// Header actions
const headerActions = (
<Button onClick={() => setIsModalOpen(true)}>
<Button onClick={() => setIsModalOpen(true)} data-testid="add-certificate-btn">
<Plus className="w-4 h-4 mr-2" />
{t('certificates.addCertificate')}
</Button>
@@ -71,7 +71,7 @@ export default function Certificates() {
{/* Upload Certificate Dialog */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent>
<DialogContent data-testid="certificate-upload-dialog">
<DialogHeader>
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
</DialogHeader>
@@ -88,6 +88,7 @@ export default function Certificates() {
<Label htmlFor="cert-file">{t('certificates.certificatePem')}</Label>
<input
id="cert-file"
data-testid="certificate-file-input"
type="file"
accept=".pem,.crt,.cer"
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
@@ -99,6 +100,7 @@ export default function Certificates() {
<Label htmlFor="key-file">{t('certificates.privateKeyPem')}</Label>
<input
id="key-file"
data-testid="certificate-key-input"
type="file"
accept=".pem,.key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}

View File

@@ -503,6 +503,7 @@ export default function ProxyHosts() {
cell: (host) => (
<Switch
checked={host.enabled}
aria-label={`${host.enabled ? 'Disable' : 'Enable'} proxy host ${host.name || host.domain_names}`}
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
/>
),
@@ -516,6 +517,7 @@ export default function ProxyHosts() {
<Button
variant="ghost"
size="sm"
aria-label={`Edit proxy host ${host.name || host.domain_names}`}
onClick={(e) => {
e.stopPropagation()
handleEdit(host)
@@ -527,6 +529,7 @@ export default function ProxyHosts() {
variant="ghost"
size="sm"
className="text-error hover:text-error hover:bg-error/10"
aria-label={`Delete proxy host ${host.name || host.domain_names}`}
onClick={(e) => {
e.stopPropagation()
handleDelete(host.uuid)

View File

@@ -28,11 +28,12 @@ export default function Settings() {
}
>
{/* Tab Navigation */}
<nav className="flex items-center gap-1 p-1 bg-surface-subtle rounded-lg w-fit">
<nav aria-label={t('settings.title')} className="flex items-center gap-1 p-1 bg-surface-subtle rounded-lg w-fit">
{navItems.map(({ path, label, icon: Icon }) => (
<Link
key={path}
to={path}
aria-current={isActive(path) ? 'page' : undefined}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all duration-fast',
isActive(path)

View File

@@ -173,10 +173,15 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
<div className="fixed inset-0 bg-black/50 z-40" onClick={handleClose} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto">
<div
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
role="dialog"
aria-modal="true"
aria-labelledby="invite-modal-title"
>
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h3 id="invite-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
<UserPlus className="h-5 w-5" />
@@ -253,10 +258,11 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
<label htmlFor="invite-user-role" className="block text-sm font-medium text-gray-300 mb-1.5">
{t('users.role')}
</label>
<select
id="invite-user-role"
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -269,10 +275,11 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
{role === 'user' && (
<>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
<label htmlFor="invite-permission-mode" className="block text-sm font-medium text-gray-300 mb-1.5">
{t('users.permissionMode')}
</label>
<select
id="invite-permission-mode"
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -444,10 +451,15 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50" role="dialog" aria-modal="true" aria-labelledby="permissions-modal-title">
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto">
<div
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
role="dialog"
aria-modal="true"
aria-labelledby="permissions-modal-title"
>
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h3 id="permissions-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="h-5 w-5" />
@@ -460,10 +472,11 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
<div className="p-4 space-y-4">
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
<label htmlFor="edit-permission-mode" className="block text-sm font-medium text-gray-300 mb-1.5">
{t('users.permissionMode')}
</label>
<select
id="edit-permission-mode"
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"

View File

@@ -292,7 +292,6 @@ test.describe('Notification Providers', () => {
});
await test.step('Verify provider appears in list', async () => {
await page.waitForTimeout(1000);
const providerInList = page.getByText(providerName);
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
});
@@ -343,7 +342,6 @@ test.describe('Notification Providers', () => {
});
await test.step('Verify provider created', async () => {
await page.waitForTimeout(1000);
const providerInList = page.getByText(providerName);
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
});
@@ -515,7 +513,6 @@ test.describe('Notification Providers', () => {
});
await test.step('Verify deletion', async () => {
await page.waitForTimeout(1000);
// Provider should be gone or success message shown
const successIndicator = page.locator('[data-testid="toast-success"]')
.or(page.getByRole('status').filter({ hasText: /deleted|removed/i }))
@@ -714,7 +711,6 @@ test.describe('Notification Providers', () => {
await test.step('Attempt to save', async () => {
await page.getByTestId('provider-save-btn').click();
await page.waitForTimeout(500);
});
await test.step('Verify name validation error', async () => {
@@ -844,7 +840,6 @@ test.describe('Notification Providers', () => {
});
await test.step('Verify template was created and appears in list', async () => {
await page.waitForTimeout(1500);
const templateInList = page.getByText(templateName);
await expect(templateInList.first()).toBeVisible({ timeout: 10000 });
});
@@ -1002,7 +997,7 @@ test.describe('Notification Providers', () => {
const saveButton = page.getByRole('button', { name: /save/i }).last();
if (await saveButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await saveButton.click();
await page.waitForTimeout(1000);
await waitForLoadingComplete(page);
}
});
});
@@ -1153,7 +1148,7 @@ test.describe('Notification Providers', () => {
const testButton = page.getByTestId('provider-test-btn');
// Wait for loading to complete and check for success icon
await page.waitForTimeout(2000);
await waitForLoadingComplete(page);
const hasSuccessIcon = await testButton.locator('svg').evaluate((el) =>
el.classList.contains('text-green-500') ||
el.closest('button')?.querySelector('.text-green-500') !== null
@@ -1195,7 +1190,7 @@ test.describe('Notification Providers', () => {
await test.step('Verify success feedback', async () => {
// Wait for success icon (checkmark)
await page.waitForTimeout(1500);
await waitForLoadingComplete(page);
const testButton = page.getByTestId('provider-test-btn');
const successIcon = testButton.locator('svg.text-green-500, svg[class*="green"]');
@@ -1618,7 +1613,7 @@ test.describe('Notification Providers', () => {
});
await test.step('Verify error feedback', async () => {
await page.waitForTimeout(1500);
await waitForLoadingComplete(page);
// Should show error icon (X)
const testButton = page.getByTestId('provider-test-btn');

View File

@@ -213,7 +213,7 @@ test.describe('SMTP Settings', () => {
await test.step('Attempt to save and verify validation', async () => {
await saveButton.click();
await page.waitForTimeout(500);
await waitForLoadingComplete(page);
// Check for validation error
const errorMessage = page.getByText(/invalid.*email|email.*format|valid.*email/i);
@@ -238,7 +238,7 @@ test.describe('SMTP Settings', () => {
await fromInput.fill('noreply@example.com');
// Should not show validation error for valid email
await page.waitForTimeout(300);
await waitForLoadingComplete(page);
const inputHasError = await fromInput.evaluate((el) =>
el.classList.contains('border-red-500')
).catch(() => false);
@@ -384,7 +384,7 @@ test.describe('SMTP Settings', () => {
await hostInput.clear();
await hostInput.fill(originalHost || 'smtp.test.local');
await saveButton.click();
await page.waitForTimeout(1000);
await waitForToast(page, /saved|success/i, { type: 'success', timeout: 10000 });
});
});
@@ -413,7 +413,7 @@ test.describe('SMTP Settings', () => {
await saveButton.click();
// Wait for save to complete
await page.waitForTimeout(1000);
await waitForToast(page, /saved|success/i, { type: 'success', timeout: 10000 });
// After save, password field may be cleared or masked
// The actual behavior depends on implementation
@@ -442,7 +442,7 @@ test.describe('SMTP Settings', () => {
await passwordInput.clear();
await passwordInput.fill('initial-password');
await saveButton.click();
await page.waitForTimeout(1000);
await waitForToast(page, /saved|success/i, { type: 'success', timeout: 10000 });
});
await test.step('Reload page', async () => {
@@ -757,7 +757,7 @@ test.describe('SMTP Settings', () => {
// Open select with Enter or Space
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
await waitForLoadingComplete(page);
// Check if listbox opened
const listbox = page.getByRole('listbox');
@@ -877,7 +877,7 @@ test.describe('SMTP Settings', () => {
// Try to save with empty required field
const saveButton = page.getByRole('button', { name: /save/i }).last();
await saveButton.click();
await page.waitForTimeout(500);
await waitForLoadingComplete(page);
});
await test.step('Verify error announcement', async () => {

View File

@@ -165,9 +165,7 @@ test.describe('User Management', () => {
.getByRole('button', { name: /send.*invite/i })
.first();
await sendButton.click();
// Wait for invite creation
await page.waitForTimeout(1000);
await waitForToast(page, /invite.*sent|invite.*created|success/i, { type: 'success', timeout: 10000 });
// Close the modal - scope to dialog to avoid strict mode violation with Toast close buttons
const closeButton = page.getByRole('dialog')
@@ -941,7 +939,7 @@ test.describe('User Management', () => {
});
await test.step('Verify user no longer in list', async () => {
await page.waitForTimeout(500);
await waitForLoadingComplete(page);
const userRow = page.getByRole('row').filter({
hasText: testUser.email,
});
@@ -1026,7 +1024,7 @@ test.describe('User Management', () => {
await sendButton.click();
// Wait for success and close modal
await page.waitForTimeout(2000);
await waitForToast(page, /invite.*sent|invite.*created|success/i, { type: 'success', timeout: 10000 });
const closeButton = page.getByRole('button', { name: /done|close|×/i }).first();
if (await closeButton.isVisible()) {
await closeButton.click();

View File

@@ -243,22 +243,38 @@ export async function waitForLoadingComplete(
return;
}
// Wait for any loading indicator to disappear
// Updated to be more specific and exclude pulsing UI badges
// Wait for visible loading indicators to disappear.
// Avoid broad class-based selectors (e.g. .loading, .spinner) to prevent
// false positives from persistent layout/status elements.
const loader = page.locator([
'[role="progressbar"]:not([aria-label*="Challenge timeout progress"])',
'[aria-busy="true"]',
'.loading-spinner',
'.loading',
'.spinner',
'[data-loading="true"]',
'[data-testid="config-reload-overlay"]',
'[data-testid="loading-spinner"]',
'[role="status"][aria-label="Loading"]',
'[role="status"][aria-label="Authenticating"]',
'[role="status"][aria-label="Security Loading"]'
].join(', '));
try {
await expect(loader).toHaveCount(0, { timeout });
await expect
.poll(async () => {
const count = await loader.count();
if (count === 0) {
return 0;
}
let visibleCount = 0;
for (let index = 0; index < count; index += 1) {
if (await loader.nth(index).isVisible().catch(() => false)) {
visibleCount += 1;
}
}
return visibleCount;
}, { timeout })
.toBe(0);
} catch (error) {
if (page.isClosed()) {
return;