mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01:00
♿️ 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:
parent
199e5e6eaf
commit
2d3dd9e351
3 changed files with 89 additions and 31 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue