fix: improve accessibility — add ARIA attributes to ThemeSwitcher, ThinkingBlock, ThinkingIndicator, ErrorBoundary, SessionIcon

- ThemeSwitcher: aria-expanded, aria-haspopup, aria-pressed on theme/accent buttons, Escape to close, dialog role
- ThinkingBlock: aria-expanded on toggle, region role on content
- ThinkingIndicator: role=status, aria-label, decorative icon aria-hidden
- ErrorBoundary: role=alert on error state
- SessionIcon: aria-hidden on decorative SVG brand icons
This commit is contained in:
Nicolas Varrot
2026-02-13 10:42:10 +00:00
parent 3657476fd9
commit 1c09ccde22
5 changed files with 24 additions and 10 deletions

View File

@@ -40,7 +40,7 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.props.fallback) return this.props.fallback;
return (
<div className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-pc-text p-6">
<div role="alert" className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-pc-text p-6">
<div className="max-w-md w-full space-y-4 text-center">
<div className="text-4xl">💥</div>
<h1 className="text-xl font-semibold text-pc-text">

View File

@@ -4,7 +4,7 @@ import type { Session } from '../types';
// SVG brand icons (not available in lucide)
function DiscordIcon({ size = 15, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
);
@@ -12,7 +12,7 @@ function DiscordIcon({ size = 15, className = '' }: { size?: number; className?:
function TelegramIcon({ size = 15, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
@@ -20,7 +20,7 @@ function TelegramIcon({ size = 15, className = '' }: { size?: number; className?
function SignalIcon({ size = 15, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M12 1.5C6.202 1.5 1.5 6.202 1.5 12c0 1.77.44 3.437 1.217 4.898L1.004 22.29a.75.75 0 0 0 .707.993l5.392-1.713A10.464 10.464 0 0 0 12 22.5c5.798 0 10.5-4.702 10.5-10.5S17.798 1.5 12 1.5z" />
</svg>
);
@@ -28,7 +28,7 @@ function SignalIcon({ size = 15, className = '' }: { size?: number; className?:
function WhatsAppIcon({ size = 15, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z" />
</svg>
);
@@ -36,7 +36,7 @@ function WhatsAppIcon({ size = 15, className = '' }: { size?: number; className?
function SlackIcon({ size = 15, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
</svg>
);

View File

@@ -51,10 +51,15 @@ export function ThemeSwitcher() {
) return;
setOpen(false);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', updatePos);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', updatePos);
};
}, [open, updatePos]);
@@ -64,6 +69,8 @@ export function ThemeSwitcher() {
<button
ref={btnRef}
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="dialog"
className="flex items-center gap-1.5 rounded-2xl border border-[var(--pc-border)] bg-[var(--pc-bg-elevated)]/30 px-2.5 py-1.5 text-xs text-[var(--pc-text-muted)] hover:text-[var(--pc-text-secondary)] hover:bg-[var(--pc-bg-elevated)]/50 transition-colors"
title={t('theme.title')}
>
@@ -72,6 +79,8 @@ export function ThemeSwitcher() {
{open && createPortal(
<div
ref={panelRef}
role="dialog"
aria-label={t('theme.title')}
className="fixed w-52 rounded-2xl border border-[var(--pc-border-strong)] bg-[var(--pc-bg-surface)] shadow-2xl p-3 animate-fade-in"
style={{ top: pos.top, right: pos.right, zIndex: 2147483647 }}
>
@@ -86,6 +95,7 @@ export function ThemeSwitcher() {
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
active
? 'border-[var(--pc-accent)]/40 bg-[var(--pc-accent)]/10 text-[var(--pc-accent-light)]'
@@ -107,6 +117,8 @@ export function ThemeSwitcher() {
key={opt.value}
onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
aria-pressed={accent === opt.value}
aria-label={`${opt.value} accent`}
style={{
backgroundColor: opt.color,
borderColor: accent === opt.value ? opt.color : 'transparent',

View File

@@ -10,6 +10,8 @@ export function ThinkingBlock({ text }: { text: string }) {
<div className="my-2">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-label={t('thinking.label')}
className="inline-flex items-center gap-1.5 rounded-2xl border border-pc-border bg-pc-elevated/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-[var(--pc-hover)] transition-colors"
>
<Brain size={13} />
@@ -17,7 +19,7 @@ export function ThinkingBlock({ text }: { text: string }) {
{open ? <ChevronDown size={12} className="ml-1 text-pc-text-muted" /> : <ChevronRight size={12} className="ml-1 text-pc-text-muted" />}
</button>
{open && (
<div className="mt-2 rounded-2xl border border-pc-border bg-pc-elevated/25 p-3 text-sm italic text-pc-text-secondary whitespace-pre-wrap max-h-96 overflow-y-auto">
<div role="region" aria-label={t('thinking.label')} className="mt-2 rounded-2xl border border-pc-border bg-pc-elevated/25 p-3 text-sm italic text-pc-text-secondary whitespace-pre-wrap max-h-96 overflow-y-auto">
{text}
</div>
)}

View File

@@ -27,13 +27,13 @@ export function ThinkingIndicator() {
};
return (
<div className="flex items-center gap-2 mt-2 animate-fade-in">
<div role="status" aria-label={t('thinking.reasoning')} className="flex items-center gap-2 mt-2 animate-fade-in">
<div className="inline-flex items-center gap-2 rounded-2xl border border-violet-500/15 bg-violet-500/5 px-3 py-1.5">
<Brain size={14} className="text-violet-300 animate-pulse" />
<Brain size={14} className="text-violet-300 animate-pulse" aria-hidden="true" />
<span className="text-xs font-medium text-violet-300">
{t('thinking.reasoning')}
</span>
<span className="text-xs tabular-nums text-violet-300/50">
<span className="text-xs tabular-nums text-violet-300/50" aria-live="off">
{formatElapsed(elapsed)}
</span>
</div>