Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/components/accessibility-dialogs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import type { TFunction } from 'i18next';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

import { ThemeProvider } from '../contexts/ThemeContext';

import ProjectCreationWizard from './project-creation-wizard/ProjectCreationWizard';
import SidebarHeader from './sidebar/view/subcomponents/SidebarHeader';
import Settings from './settings/view/Settings';

const noop = () => {};

test('settings and project creation surfaces expose labelled modal dialogs', () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
key: () => null,
length: 0,
} satisfies Storage,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
matchMedia: () => ({ matches: false }),
},
});

const settingsHtml = renderToStaticMarkup(
createElement(ThemeProvider, null, createElement(Settings, {
isOpen: true,
onClose: noop,
})),
);
assert.match(settingsHtml, /role="dialog"/);
assert.match(settingsHtml, /aria-modal="true"/);
assert.match(settingsHtml, /aria-labelledby="settings-dialog-title"/);
assert.match(settingsHtml, /aria-label="(?:Close|common:buttons\.close)"/);

const projectHtml = renderToStaticMarkup(createElement(ProjectCreationWizard, {
onClose: noop,
}));
assert.match(projectHtml, /role="dialog"/);
assert.match(projectHtml, /aria-modal="true"/);
assert.match(projectHtml, /aria-labelledby="project-creation-dialog-title"/);
assert.match(projectHtml, /aria-label="(?:Close|buttons\.close)"/);
});

test('mobile sidebar icon buttons have translated accessible names', () => {
const translations: Record<string, string> = {
'tooltips.refresh': 'Refresh projects and sessions',
'tooltips.createProject': 'Create new project',
};
const t = ((key: string, fallback?: string) => translations[key] ?? fallback ?? key) as TFunction;
const html = renderToStaticMarkup(createElement(SidebarHeader, {
isPWA: false,
isMobile: true,
isLoading: false,
projectsCount: 0,
runningSessionsCount: 0,
archivedSessionsCount: 0,
isArchivedSessionsLoading: false,
searchFilter: '',
onSearchFilterChange: noop,
onClearSearchFilter: noop,
searchMode: 'projects',
onSearchModeChange: noop,
onRefresh: noop,
isRefreshing: false,
onCreateProject: noop,
onCollapseSidebar: noop,
t,
}));

assert.match(html, /aria-label="Refresh projects and sessions"/);
assert.match(html, /aria-label="Create new project"/);
});
39 changes: 36 additions & 3 deletions src/components/project-creation-wizard/ProjectCreationWizard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FolderPlus, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';

Expand Down Expand Up @@ -30,12 +30,37 @@ export default function ProjectCreationWizard({
onProjectCreated,
}: ProjectCreationWizardProps) {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const [step, setStep] = useState<WizardStep>(1);
const [formState, setFormState] = useState<WizardFormState>(initialFormState);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cloneProgress, setCloneProgress] = useState('');

useEffect(() => {
triggerRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const focusFrame = requestAnimationFrame(() => dialogRef.current?.focus());

return () => {
cancelAnimationFrame(focusFrame);
triggerRef.current?.focus();
};
}, []);

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isCreating) {
event.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isCreating, onClose]);

const shouldLoadTokens =
step === 1 && shouldShowGithubAuthentication(formState.githubUrl);

Expand Down Expand Up @@ -132,18 +157,26 @@ export default function ProjectCreationWizard({

return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[60] flex items-center justify-center bg-black/50 p-0 backdrop-blur-sm sm:p-4">
<div className="h-full w-full overflow-y-auto rounded-none border-0 border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800 sm:h-auto sm:max-w-2xl sm:rounded-lg sm:border">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="project-creation-dialog-title"
tabIndex={-1}
className="h-full w-full overflow-y-auto rounded-none border-0 border-gray-200 bg-white shadow-xl outline-none dark:border-gray-700 dark:bg-gray-800 sm:h-auto sm:max-w-2xl sm:rounded-lg sm:border"
>
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
<FolderPlus className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
<h3 id="project-creation-dialog-title" className="text-lg font-semibold text-gray-900 dark:text-white">
{t('projectWizard.title')}
</h3>
</div>
<button
onClick={onClose}
aria-label={t('buttons.close')}
className="rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
disabled={isCreating}
>
Expand Down
45 changes: 43 additions & 2 deletions src/components/settings/view/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';

Expand All @@ -12,6 +13,10 @@ import type { SettingsProps } from '../types/types';

function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
const { t } = useTranslation('settings');
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const {
activeTab,
setActiveTab,
Expand All @@ -37,6 +42,34 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
initialTab,
});

useEffect(() => {
if (!isOpen) return;

triggerRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const focusFrame = requestAnimationFrame(() => dialogRef.current?.focus());

return () => {
cancelAnimationFrame(focusFrame);
triggerRef.current?.focus();
triggerRef.current = null;
};
}, [isOpen]);

useEffect(() => {
if (!isOpen || showLoginModal) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onCloseRef.current();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, showLoginModal]);

if (!isOpen) {
return null;
}
Expand All @@ -45,10 +78,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set

return (
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4">
<div className="flex h-full w-full flex-col overflow-hidden border border-border bg-background shadow-2xl md:h-[90vh] md:max-w-4xl md:rounded-xl">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="settings-dialog-title"
tabIndex={-1}
className="flex h-full w-full flex-col overflow-hidden border border-border bg-background shadow-2xl outline-none md:h-[90vh] md:max-w-4xl md:rounded-xl"
>
{/* Header */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 md:px-5">
<h2 className="text-base font-semibold text-foreground">{t('title')}</h2>
<h2 id="settings-dialog-title" className="text-base font-semibold text-foreground">{t('title')}</h2>
<div className="flex items-center gap-2">
{saveStatus === 'success' && (
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
Expand All @@ -57,6 +97,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
variant="ghost"
size="sm"
onClick={onClose}
aria-label={t('common:buttons.close')}
className="h-10 w-10 touch-manipulation p-0 text-muted-foreground hover:text-foreground active:bg-accent/50"
>
<X className="h-5 w-5" />
Expand Down
2 changes: 2 additions & 0 deletions src/components/sidebar/view/subcomponents/SidebarHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,14 @@ export default function SidebarHeader({
className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95"
onClick={onRefresh}
disabled={isRefreshing}
aria-label={t('tooltips.refresh')}
>
<RefreshCw className={`h-4 w-4 text-muted-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95"
onClick={onCreateProject}
aria-label={t('tooltips.createProject')}
>
<FolderPlus className="h-4 w-4" />
</button>
Expand Down