️ a11y: Enhance Accessibility in ToolSelectDialog, ThemeSelector and ChatGroupItem (#5395)

* feat: Add keyboard shortcut for theme switching and improve accessibility announcements

* fix: Improve accessibility of ToolSelectDialog close button

* feat: Enhance accessibility in ChatGroupItem component
This commit is contained in:
Marco Beretta 2025-01-22 03:54:13 +01:00 committed by GitHub
parent 199e5e6eaf
commit 2d3dd9e351
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 89 additions and 31 deletions

View file

@ -27,6 +27,7 @@ function ChatGroupItem({
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false); const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false); const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const onEditClick = useCustomLink<HTMLDivElement>(`/d/prompts/${group._id}`); const onEditClick = useCustomLink<HTMLDivElement>(`/d/prompts/${group._id}`);
const groupIsGlobal = useMemo( const groupIsGlobal = useMemo(
() => instanceProjectId != null && group.projectIds?.includes(instanceProjectId), () => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
[group, instanceProjectId], [group, instanceProjectId],
@ -34,13 +35,14 @@ function ChatGroupItem({
const isOwner = useMemo(() => user?.id === group.author, [user, group]); const isOwner = useMemo(() => user?.id === group.author, [user, group]);
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => { const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
const text = group.productionPrompt?.prompt ?? ''; const text = group.productionPrompt?.prompt;
if (!text) { if (!text?.trim()) {
return; return;
} }
const hasVariables = detectVariables(text);
if (hasVariables) { if (detectVariables(text)) {
return setVariableDialogOpen(true); setVariableDialogOpen(true);
return;
} }
submitPrompt(text); submitPrompt(text);
@ -59,33 +61,47 @@ function ChatGroupItem({
} }
> >
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
{groupIsGlobal === true && <EarthIcon className="icon-md text-green-400" />} {groupIsGlobal === true && (
<EarthIcon className="icon-md text-green-400" aria-label="Global prompt group" />
)}
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
id="prompts-menu-trigger" id={`prompt-actions-${group._id}`}
aria-label="prompts-menu-trigger" aria-label={`${group.name} - Actions Menu`}
aria-expanded="false"
aria-controls={`prompt-menu-${group._id}`}
aria-haspopup="menu"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
className="z-50 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-secondary focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" className="z-50 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-secondary focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
> >
<MenuIcon className="icon-md text-text-secondary" /> <MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">Open actions menu for {group.name}</span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
id={`prompt-menu-${group._id}`}
aria-label={`Available actions for ${group.name}`}
className="z-50 mt-2 w-36 rounded-lg" className="z-50 mt-2 w-36 rounded-lg"
collisionPadding={2} collisionPadding={2}
align="end" align="end"
> >
<DropdownMenuItem <DropdownMenuItem
role="menuitem"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPreviewDialogOpen(true); setPreviewDialogOpen(true);
}} }}
className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
> >
<TextSearch className="mr-2 h-4 w-4" /> <TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
<span>{localize('com_ui_preview')}</span> <span>{localize('com_ui_preview')}</span>
</DropdownMenuItem> </DropdownMenuItem>
{isOwner && ( {isOwner && (
@ -98,7 +114,7 @@ function ChatGroupItem({
onEditClick(e); onEditClick(e);
}} }}
> >
<EditIcon className="mr-2 h-4 w-4" /> <EditIcon className="mr-2 h-4 w-4" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span> <span>{localize('com_ui_edit')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -178,10 +178,11 @@ function ToolSelectDialog({
setIsOpen(false); setIsOpen(false);
setCurrentPage(1); setCurrentPage(1);
}} }}
className="inline-block text-text-tertiary hover:text-text-secondary" className="inline-block rounded-full text-text-secondary transition-colors hover:text-text-primary"
tabIndex={0} aria-label="Close dialog"
type="button"
> >
<X /> <X aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,7 +1,13 @@
import React, { useContext, useCallback, useEffect } from 'react'; import React, { useContext, useCallback, useEffect, useState } from 'react';
import { Sun, Moon, Monitor } from 'lucide-react'; import { Sun, Moon, Monitor } from 'lucide-react';
import { ThemeContext } from '~/hooks'; import { ThemeContext } from '~/hooks';
declare global {
interface Window {
lastThemeChange?: number;
}
}
const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => { const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => {
const themeIcons = { const themeIcons = {
system: <Monitor />, system: <Monitor />,
@ -9,32 +15,55 @@ const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) =
light: <Sun />, light: <Sun />,
}; };
const nextTheme = theme === 'dark' ? 'light' : 'dark';
const label = `Switch to ${nextTheme} theme`;
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
onChange(nextTheme);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [nextTheme, onChange]);
return ( return (
<div className="flex items-center justify-between"> <button
<button className="flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
className="cursor-pointer" aria-label={label}
onClick={() => onChange(theme === 'dark' ? 'light' : 'dark')} aria-keyshortcuts="Ctrl+Shift+T"
onKeyDown={(e) => { onClick={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault();
onChange(theme === 'dark' ? 'light' : 'dark'); onChange(nextTheme);
} }}
}} onKeyDown={(e) => {
role="switch" if (e.key === 'Enter' || e.key === ' ') {
aria-checked={theme === 'dark'} e.preventDefault();
tabIndex={0} onChange(nextTheme);
> }
{themeIcons[theme]} }}
</button> >
</div> {themeIcons[theme]}
</button>
); );
}; };
const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => { const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
const { theme, setTheme } = useContext(ThemeContext); const { theme, setTheme } = useContext(ThemeContext);
const [announcement, setAnnouncement] = useState('');
const changeTheme = useCallback( const changeTheme = useCallback(
(value: string) => { (value: string) => {
const now = Date.now();
if (typeof window.lastThemeChange === 'number' && now - window.lastThemeChange < 500) {
return;
}
window.lastThemeChange = now;
setTheme(value); setTheme(value);
setAnnouncement(value === 'dark' ? 'Dark theme enabled' : 'Light theme enabled');
}, },
[setTheme], [setTheme],
); );
@ -46,6 +75,13 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
} }
}, [theme, setTheme]); }, [theme, setTheme]);
useEffect(() => {
if (announcement) {
const timeout = setTimeout(() => setAnnouncement(''), 1000);
return () => clearTimeout(timeout);
}
}, [announcement]);
if (returnThemeOnly === true) { if (returnThemeOnly === true) {
return <Theme theme={theme} onChange={changeTheme} />; return <Theme theme={theme} onChange={changeTheme} />;
} }
@ -55,6 +91,11 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
<div className="absolute bottom-0 left-0 m-4"> <div className="absolute bottom-0 left-0 m-4">
<Theme theme={theme} onChange={changeTheme} /> <Theme theme={theme} onChange={changeTheme} />
</div> </div>
{announcement && (
<div aria-live="polite" className="sr-only">
{announcement}
</div>
)}
</div> </div>
); );
}; };