mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Add comprehensive accessibility features to context management modals
- Add ARIA labels with aria-labelledby/aria-describedby to dialogs - Add role="tablist", role="tab" for view mode switchers - Add role="listbox", role="option" for session/agent selection - Add screen reader announcements via useAnnouncement hook - Add visually hidden descriptions for modal instructions - Add aria-expanded/aria-controls for expandable session groups - Add aria-invalid/aria-describedby for error states - Add fieldset/legend for option checkboxes - Add aria-hidden to decorative icons - Add aria-busy for loading states - Update tests to reflect new ARIA structure
This commit is contained in:
@@ -261,7 +261,8 @@ describe('SendToAgentModal', () => {
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-label', 'Send Context to Agent');
|
||||
expect(dialog).toHaveAttribute('aria-labelledby', 'send-to-agent-title');
|
||||
expect(dialog).toHaveAttribute('aria-describedby', 'send-to-agent-description');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -417,7 +418,7 @@ describe('SendToAgentModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText('Close modal');
|
||||
const closeButton = screen.getByLabelText('Close dialog');
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -682,6 +683,14 @@ describe('SendToAgentModal', () => {
|
||||
// Navigate to second item (first is Claude Code which is current)
|
||||
fireEvent.keyDown(dialog, { key: 'ArrowRight' });
|
||||
|
||||
// Wait for state to update after navigation
|
||||
await waitFor(() => {
|
||||
// The second option (OpenCode) should now be highlighted
|
||||
const options = screen.getAllByRole('option');
|
||||
// Check that we have navigated (just verify options exist)
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// Press Space to select
|
||||
fireEvent.keyDown(dialog, { key: ' ' });
|
||||
|
||||
@@ -751,7 +760,7 @@ describe('SendToAgentModal', () => {
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
|
||||
it('has semantic button elements for agents', () => {
|
||||
it('has semantic elements for agents and action buttons', () => {
|
||||
renderWithLayerStack(
|
||||
<SendToAgentModal
|
||||
theme={testTheme}
|
||||
@@ -764,9 +773,13 @@ describe('SendToAgentModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Should have buttons for each visible agent + Cancel + Send + Close
|
||||
// Should have action buttons (Cancel, Send, Close)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(7); // 4 agents + 3 action buttons
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Agent options should be rendered as options in a listbox
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBe(4); // 4 agents
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { useListNavigation } from '../hooks/useListNavigation';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { formatTokensCompact } from '../utils/formatters';
|
||||
import { ScreenReaderAnnouncement, useAnnouncement } from './Wizard/ScreenReaderAnnouncement';
|
||||
|
||||
/**
|
||||
* View modes for the modal
|
||||
@@ -184,6 +185,9 @@ export function MergeSessionModal({
|
||||
// Merge state
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
|
||||
// Screen reader announcements
|
||||
const { announce, announcementProps } = useAnnouncement();
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const layerIdRef = useRef<string>();
|
||||
@@ -371,6 +375,39 @@ export function MergeSessionModal({
|
||||
setSelectedIndex(0);
|
||||
}, [searchQuery, viewMode, setSelectedIndex]);
|
||||
|
||||
// Announce search results to screen readers
|
||||
useEffect(() => {
|
||||
if (viewMode === 'search' && isOpen) {
|
||||
const sessionCount = groupedItems.size;
|
||||
const tabCount = filteredItems.length;
|
||||
if (searchQuery) {
|
||||
announce(
|
||||
`Found ${tabCount} tab${tabCount !== 1 ? 's' : ''} across ${sessionCount} session${sessionCount !== 1 ? 's' : ''}`
|
||||
);
|
||||
} else if (tabCount > 0) {
|
||||
announce(
|
||||
`${tabCount} tab${tabCount !== 1 ? 's' : ''} available across ${sessionCount} session${sessionCount !== 1 ? 's' : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [viewMode, filteredItems.length, groupedItems.size, searchQuery, isOpen, announce]);
|
||||
|
||||
// Announce target selection
|
||||
useEffect(() => {
|
||||
if (selectedTarget) {
|
||||
announce(
|
||||
`Selected: ${selectedTarget.sessionName} - ${selectedTarget.tabName}, approximately ${formatTokensCompact(selectedTarget.estimatedTokens)} tokens`
|
||||
);
|
||||
}
|
||||
}, [selectedTarget, announce]);
|
||||
|
||||
// Announce merge status
|
||||
useEffect(() => {
|
||||
if (isMerging) {
|
||||
announce('Merging contexts, please wait...', 'assertive');
|
||||
}
|
||||
}, [isMerging, announce]);
|
||||
|
||||
// Toggle session expansion
|
||||
const toggleSession = useCallback((sessionId: string) => {
|
||||
setExpandedSessions(prev => {
|
||||
@@ -496,10 +533,14 @@ export function MergeSessionModal({
|
||||
className="fixed inset-0 modal-overlay flex items-start justify-center pt-16 z-[9999] animate-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Merge Session Contexts"
|
||||
aria-labelledby="merge-modal-title"
|
||||
aria-describedby="merge-modal-description"
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Screen reader announcements */}
|
||||
<ScreenReaderAnnouncement {...announcementProps} />
|
||||
|
||||
<div
|
||||
className="w-[600px] rounded-xl shadow-2xl border outline-none flex flex-col animate-slide-up"
|
||||
style={{
|
||||
@@ -515,8 +556,9 @@ export function MergeSessionModal({
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
<GitMerge className="w-5 h-5" style={{ color: theme.colors.accent }} aria-hidden="true" />
|
||||
<h2
|
||||
id="merge-modal-title"
|
||||
className="text-sm font-bold"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
@@ -528,16 +570,23 @@ export function MergeSessionModal({
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
aria-label="Close modal"
|
||||
aria-label="Close merge dialog"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description for screen readers */}
|
||||
<p id="merge-modal-description" className="sr-only">
|
||||
Select a session or tab to merge with the current context. Use Tab to switch between Paste ID, Search Sessions, and Recent modes. Use arrow keys to navigate the list.
|
||||
</p>
|
||||
|
||||
{/* View Mode Tabs */}
|
||||
<div
|
||||
className="px-4 pt-3 pb-2 border-b flex gap-1"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
role="tablist"
|
||||
aria-label="Selection mode"
|
||||
>
|
||||
{[
|
||||
{ mode: 'paste' as ViewMode, label: 'Paste ID', icon: Clipboard },
|
||||
@@ -546,6 +595,9 @@ export function MergeSessionModal({
|
||||
].map(({ mode, label, icon: Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
aria-controls={`merge-tabpanel-${mode}`}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium flex items-center gap-1.5 transition-colors"
|
||||
style={{
|
||||
@@ -553,7 +605,7 @@ export function MergeSessionModal({
|
||||
color: viewMode === mode ? theme.colors.accentForeground : theme.colors.textDim,
|
||||
}}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
@@ -563,14 +615,25 @@ export function MergeSessionModal({
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{/* Paste ID View */}
|
||||
{viewMode === 'paste' && (
|
||||
<div className="p-4 space-y-3">
|
||||
<div
|
||||
id="merge-tabpanel-paste"
|
||||
role="tabpanel"
|
||||
aria-labelledby="merge-tab-paste"
|
||||
className="p-4 space-y-3"
|
||||
>
|
||||
<div className="relative">
|
||||
<label htmlFor="paste-id-input" className="sr-only">
|
||||
Session or tab ID
|
||||
</label>
|
||||
<input
|
||||
id="paste-id-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Paste session or tab ID..."
|
||||
value={pastedId}
|
||||
onChange={(e) => setPastedId(e.target.value)}
|
||||
aria-invalid={pastedIdValid === false}
|
||||
aria-describedby={pastedIdValid === false ? "paste-id-error" : undefined}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm outline-none transition-colors"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
@@ -583,7 +646,7 @@ export function MergeSessionModal({
|
||||
}}
|
||||
/>
|
||||
{pastedIdValid !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2" aria-hidden="true">
|
||||
{pastedIdValid ? (
|
||||
<Check className="w-4 h-4" style={{ color: theme.colors.success }} />
|
||||
) : (
|
||||
@@ -601,6 +664,8 @@ export function MergeSessionModal({
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
borderColor: theme.colors.success,
|
||||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
@@ -611,7 +676,7 @@ export function MergeSessionModal({
|
||||
</div>
|
||||
{pastedIdMatch.tabName && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" style={{ color: theme.colors.textDim }} />
|
||||
<ChevronRight className="w-3 h-3" style={{ color: theme.colors.textDim }} aria-hidden="true" />
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
@@ -632,8 +697,10 @@ export function MergeSessionModal({
|
||||
|
||||
{pastedIdValid === false && pastedId.trim() && (
|
||||
<div
|
||||
id="paste-id-error"
|
||||
className="text-xs"
|
||||
style={{ color: theme.colors.error }}
|
||||
role="alert"
|
||||
>
|
||||
No matching session or tab found for this ID
|
||||
</div>
|
||||
@@ -643,20 +710,31 @@ export function MergeSessionModal({
|
||||
|
||||
{/* Search Sessions View */}
|
||||
{viewMode === 'search' && (
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div
|
||||
id="merge-tabpanel-search"
|
||||
role="tabpanel"
|
||||
aria-labelledby="merge-tab-search"
|
||||
className="flex flex-col min-h-0"
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="p-4 pb-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="search-sessions-input" className="sr-only">
|
||||
Search sessions and tabs
|
||||
</label>
|
||||
<input
|
||||
id="search-sessions-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search sessions and tabs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-controls="session-list"
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
@@ -669,13 +747,17 @@ export function MergeSessionModal({
|
||||
|
||||
{/* Session/Tab List */}
|
||||
<div
|
||||
id="session-list"
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-2 pb-2"
|
||||
role="listbox"
|
||||
aria-label="Available sessions and tabs"
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div
|
||||
className="p-4 text-center text-sm"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
role="status"
|
||||
>
|
||||
{searchQuery ? 'No matching sessions found' : 'No other sessions available'}
|
||||
</div>
|
||||
@@ -685,16 +767,18 @@ export function MergeSessionModal({
|
||||
const sessionName = items[0].sessionName;
|
||||
|
||||
return (
|
||||
<div key={sessionId} className="mb-1">
|
||||
<div key={sessionId} className="mb-1" role="group" aria-label={`Session: ${sessionName}`}>
|
||||
{/* Session Header */}
|
||||
<button
|
||||
onClick={() => toggleSession(sessionId)}
|
||||
className="w-full px-2 py-1.5 flex items-center gap-2 rounded hover:bg-white/5 transition-colors"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`session-tabs-${sessionId}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
<ChevronDown className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
<ChevronRight className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
@@ -712,7 +796,12 @@ export function MergeSessionModal({
|
||||
|
||||
{/* Tabs */}
|
||||
{isExpanded && (
|
||||
<div className="ml-4 border-l pl-2" style={{ borderColor: theme.colors.border }}>
|
||||
<div
|
||||
id={`session-tabs-${sessionId}`}
|
||||
className="ml-4 border-l pl-2"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
role="group"
|
||||
>
|
||||
{items.map((item, itemIndex) => {
|
||||
const flatIndex = filteredItems.indexOf(item);
|
||||
const isSelected = flatIndex === selectedIndex;
|
||||
@@ -723,6 +812,8 @@ export function MergeSessionModal({
|
||||
key={item.tabId}
|
||||
ref={isSelected ? selectedItemRef : undefined}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
role="option"
|
||||
aria-selected={isTarget}
|
||||
className={`w-full px-2 py-2 flex items-center gap-2 rounded text-left transition-all duration-150 ${isTarget ? 'animate-highlight-pulse' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isTarget
|
||||
@@ -739,7 +830,7 @@ export function MergeSessionModal({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{isTarget && (
|
||||
<Check className="w-3.5 h-3.5 shrink-0 animate-check-pop" />
|
||||
<Check className="w-3.5 h-3.5 shrink-0 animate-check-pop" aria-hidden="true" />
|
||||
)}
|
||||
<span className="text-sm truncate">
|
||||
{item.tabName}
|
||||
@@ -755,6 +846,7 @@ export function MergeSessionModal({
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
aria-label={`Session ID: ${item.agentSessionId}`}
|
||||
>
|
||||
{item.agentSessionId.split('-')[0].toUpperCase()}
|
||||
</span>
|
||||
@@ -768,6 +860,7 @@ export function MergeSessionModal({
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
aria-label={`approximately ${formatTokensCompact(item.estimatedTokens)} tokens`}
|
||||
>
|
||||
~{formatTokensCompact(item.estimatedTokens)}
|
||||
</span>
|
||||
@@ -786,78 +879,90 @@ export function MergeSessionModal({
|
||||
|
||||
{/* Recent View */}
|
||||
{viewMode === 'recent' && (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div
|
||||
id="merge-tabpanel-recent"
|
||||
role="tabpanel"
|
||||
aria-labelledby="merge-tab-recent"
|
||||
className="flex-1 overflow-y-auto p-2"
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div
|
||||
className="p-4 text-center text-sm"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
role="status"
|
||||
>
|
||||
No recent sessions
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isTarget = selectedTarget?.tabId === item.tabId;
|
||||
<div role="listbox" aria-label="Recent sessions">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isTarget = selectedTarget?.tabId === item.tabId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.sessionId}-${item.tabId}`}
|
||||
ref={isSelected ? selectedItemRef : undefined}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
className={`w-full px-3 py-2.5 flex items-center gap-3 rounded-lg text-left transition-all duration-150 mb-1 ${isTarget ? 'animate-highlight-pulse' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isTarget
|
||||
? theme.colors.accent
|
||||
: isSelected
|
||||
? `${theme.colors.accent}40`
|
||||
: 'transparent',
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textMain,
|
||||
'--pulse-color': `${theme.colors.accent}40`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{isTarget && (
|
||||
<Check className="w-4 h-4 shrink-0 animate-check-pop" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{item.sessionName}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 shrink-0"
|
||||
style={{
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-sm truncate"
|
||||
style={{
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
>
|
||||
{item.tabName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs shrink-0"
|
||||
return (
|
||||
<button
|
||||
key={`${item.sessionId}-${item.tabId}`}
|
||||
ref={isSelected ? selectedItemRef : undefined}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
role="option"
|
||||
aria-selected={isTarget}
|
||||
className={`w-full px-3 py-2.5 flex items-center gap-3 rounded-lg text-left transition-all duration-150 mb-1 ${isTarget ? 'animate-highlight-pulse' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isTarget
|
||||
? theme.colors.accent
|
||||
: isSelected
|
||||
? `${theme.colors.accent}40`
|
||||
: 'transparent',
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
: theme.colors.textMain,
|
||||
'--pulse-color': `${theme.colors.accent}40`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
~{formatTokensCompact(item.estimatedTokens)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
{isTarget && (
|
||||
<Check className="w-4 h-4 shrink-0 animate-check-pop" aria-hidden="true" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{item.sessionName}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 shrink-0"
|
||||
style={{
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="text-sm truncate"
|
||||
style={{
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
>
|
||||
{item.tabName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs shrink-0"
|
||||
style={{
|
||||
color: isTarget
|
||||
? theme.colors.accentForeground
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
aria-label={`approximately ${formatTokensCompact(item.estimatedTokens)} tokens`}
|
||||
>
|
||||
~{formatTokensCompact(item.estimatedTokens)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -867,11 +972,16 @@ export function MergeSessionModal({
|
||||
<div
|
||||
className="p-4 border-t space-y-3"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
role="region"
|
||||
aria-label="Merge preview and options"
|
||||
>
|
||||
{/* Token Preview */}
|
||||
<div
|
||||
className="p-3 rounded-lg text-xs space-y-1"
|
||||
style={{ backgroundColor: theme.colors.bgMain }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Token estimate"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: theme.colors.textDim }}>
|
||||
@@ -922,7 +1032,8 @@ export function MergeSessionModal({
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-2">
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="sr-only">Merge options</legend>
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
@@ -932,8 +1043,9 @@ export function MergeSessionModal({
|
||||
checked={options.groomContext}
|
||||
onChange={(e) => setOptions(prev => ({ ...prev, groomContext: e.target.checked }))}
|
||||
className="rounded"
|
||||
aria-describedby="groom-context-desc"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-xs" id="groom-context-desc">
|
||||
Groom context with AI (removes duplicates)
|
||||
</span>
|
||||
</label>
|
||||
@@ -947,12 +1059,13 @@ export function MergeSessionModal({
|
||||
checked={options.createNewSession}
|
||||
onChange={(e) => setOptions(prev => ({ ...prev, createNewSession: e.target.checked }))}
|
||||
className="rounded"
|
||||
aria-describedby="create-new-session-desc"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-xs" id="create-new-session-desc">
|
||||
Create new session (vs. merge into current)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { formatTokensCompact } from '../utils/formatters';
|
||||
import { getAgentIcon } from '../constants/agentIcons';
|
||||
import { ScreenReaderAnnouncement, useAnnouncement } from './Wizard/ScreenReaderAnnouncement';
|
||||
|
||||
/**
|
||||
* Agent availability status for display in the selection grid
|
||||
@@ -138,6 +139,9 @@ export function SendToAgentModal({
|
||||
// Sending state
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// Screen reader announcements
|
||||
const { announce, announcementProps } = useAnnouncement();
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const layerIdRef = useRef<string>();
|
||||
@@ -282,6 +286,41 @@ export function SendToAgentModal({
|
||||
setSelectedIndex(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Announce search results to screen readers
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const availableCount = filteredAgents.filter(
|
||||
a => a.status !== 'current' && a.status !== 'unavailable'
|
||||
).length;
|
||||
if (searchQuery) {
|
||||
announce(
|
||||
`Found ${availableCount} available agent${availableCount !== 1 ? 's' : ''} matching "${searchQuery}"`
|
||||
);
|
||||
} else if (filteredAgents.length > 0) {
|
||||
announce(
|
||||
`${availableCount} agent${availableCount !== 1 ? 's' : ''} available for transfer`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [filteredAgents, searchQuery, isOpen, announce]);
|
||||
|
||||
// Announce agent selection
|
||||
useEffect(() => {
|
||||
if (selectedAgentId) {
|
||||
const agent = agents.find(a => a.id === selectedAgentId);
|
||||
if (agent) {
|
||||
announce(`Selected: ${agent.name}`);
|
||||
}
|
||||
}
|
||||
}, [selectedAgentId, agents, announce]);
|
||||
|
||||
// Announce sending status
|
||||
useEffect(() => {
|
||||
if (isSending) {
|
||||
announce('Sending context to agent, please wait...', 'assertive');
|
||||
}
|
||||
}, [isSending, announce]);
|
||||
|
||||
// Handle agent selection
|
||||
const handleSelectAgent = useCallback((agentId: ToolType) => {
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
@@ -427,10 +466,14 @@ export function SendToAgentModal({
|
||||
className="fixed inset-0 modal-overlay flex items-start justify-center pt-16 z-[9999] animate-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Send Context to Agent"
|
||||
aria-labelledby="send-to-agent-title"
|
||||
aria-describedby="send-to-agent-description"
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Screen reader announcements */}
|
||||
<ScreenReaderAnnouncement {...announcementProps} />
|
||||
|
||||
<div
|
||||
className="w-[600px] rounded-xl shadow-2xl border outline-none flex flex-col animate-slide-up"
|
||||
style={{
|
||||
@@ -446,8 +489,9 @@ export function SendToAgentModal({
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
<ArrowRight className="w-5 h-5" style={{ color: theme.colors.accent }} aria-hidden="true" />
|
||||
<h2
|
||||
id="send-to-agent-title"
|
||||
className="text-sm font-bold"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
@@ -459,12 +503,17 @@ export function SendToAgentModal({
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
aria-label="Close modal"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description for screen readers */}
|
||||
<p id="send-to-agent-description" className="sr-only">
|
||||
Select an AI agent to transfer your current context to. Use arrow keys to navigate the grid and Enter or Space to select.
|
||||
</p>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{/* Search Input */}
|
||||
@@ -473,13 +522,19 @@ export function SendToAgentModal({
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="search-agents-input" className="sr-only">
|
||||
Search agents
|
||||
</label>
|
||||
<input
|
||||
id="search-agents-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search agents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-controls="agent-grid"
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
@@ -491,16 +546,22 @@ export function SendToAgentModal({
|
||||
</div>
|
||||
|
||||
{/* Agent Grid */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div
|
||||
id="agent-grid"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4"
|
||||
role="listbox"
|
||||
aria-label="Available agents"
|
||||
>
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div
|
||||
className="p-4 text-center text-sm"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
role="status"
|
||||
>
|
||||
{searchQuery ? 'No matching agents found' : 'No agents available'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2" role="presentation">
|
||||
{filteredAgents.map((agent, index) => {
|
||||
const isHighlighted = index === selectedIndex;
|
||||
const isSelected = selectedAgentId === agent.id;
|
||||
@@ -512,6 +573,10 @@ export function SendToAgentModal({
|
||||
ref={isHighlighted ? selectedItemRef : undefined}
|
||||
onClick={() => !isDisabled && handleSelectAgent(agent.id)}
|
||||
disabled={isDisabled}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={isDisabled}
|
||||
aria-label={`${agent.name}, ${getStatusLabel(agent.status)}${index < 9 ? `, press ${index + 1} to select` : ''}`}
|
||||
className={`p-3 rounded-lg border text-center transition-all duration-150 disabled:cursor-not-allowed ${isSelected ? 'animate-highlight-pulse' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
@@ -529,7 +594,7 @@ export function SendToAgentModal({
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Agent Icon */}
|
||||
<div className="text-2xl mb-1">
|
||||
<div className="text-2xl mb-1" aria-hidden="true">
|
||||
{getAgentIcon(agent.id)}
|
||||
</div>
|
||||
|
||||
@@ -557,6 +622,7 @@ export function SendToAgentModal({
|
||||
? theme.colors.warning
|
||||
: theme.colors.textDim,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{agent.status === 'ready' && <Check className="w-3 h-3" />}
|
||||
{agent.status === 'busy' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
@@ -569,6 +635,7 @@ export function SendToAgentModal({
|
||||
<div
|
||||
className="text-[10px] mt-1 opacity-50"
|
||||
style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.textDim }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
Press {index + 1}
|
||||
</div>
|
||||
@@ -585,11 +652,16 @@ export function SendToAgentModal({
|
||||
<div
|
||||
className="p-4 border-t space-y-3"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
role="region"
|
||||
aria-label="Transfer preview and options"
|
||||
>
|
||||
{/* Token Preview */}
|
||||
<div
|
||||
className="p-3 rounded-lg text-xs space-y-1"
|
||||
style={{ backgroundColor: theme.colors.bgMain }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Token estimate"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: theme.colors.textDim }}>
|
||||
@@ -609,7 +681,7 @@ export function SendToAgentModal({
|
||||
className="flex items-center gap-1"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
New session
|
||||
</span>
|
||||
</div>
|
||||
@@ -628,7 +700,8 @@ export function SendToAgentModal({
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-2">
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="sr-only">Transfer options</legend>
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
@@ -638,8 +711,9 @@ export function SendToAgentModal({
|
||||
checked={options.groomContext}
|
||||
onChange={(e) => setOptions(prev => ({ ...prev, groomContext: e.target.checked }))}
|
||||
className="rounded"
|
||||
aria-describedby="groom-context-send-desc"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-xs" id="groom-context-send-desc">
|
||||
Groom context for target agent
|
||||
</span>
|
||||
</label>
|
||||
@@ -653,12 +727,13 @@ export function SendToAgentModal({
|
||||
checked={options.createNewSession}
|
||||
onChange={(e) => setOptions(prev => ({ ...prev, createNewSession: e.target.checked }))}
|
||||
className="rounded"
|
||||
aria-describedby="create-new-session-send-desc"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-xs" id="create-new-session-send-desc">
|
||||
Create new session
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -681,6 +756,7 @@ export function SendToAgentModal({
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
aria-busy={isSending}
|
||||
className="px-4 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
@@ -689,12 +765,12 @@ export function SendToAgentModal({
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<ArrowRight className="w-4 h-4" aria-hidden="true" />
|
||||
Send to Agent
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user