🤲 a11y: Sidebar Text Contrast (#3665)

* fix: use theme class for valid contrast for light/dark, conversation group names, fix type issues

* fix: searchbar text contrast styling, closes #3469

* style: add theming for prompt cards, fix a11y contrast issues

* a11y: address placeholder text contrast issues in LLM controls section

* chore: Add conditional check for pull request repository in a11y workflow

* style: Update text color contrast to WCAG standard for placeholder and focus states in AssistantPanel components
This commit is contained in:
Danny Avila 2024-08-16 13:50:47 -04:00 committed by GitHub
parent f8a5dad265
commit 91fc89076f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 62 additions and 51 deletions

View file

@ -4,15 +4,23 @@ on:
pull_request: pull_request:
paths: paths:
- 'client/src/**' - 'client/src/**'
workflow_dispatch:
inputs:
run_workflow:
description: 'Set to true to run this workflow'
required: true
default: 'false'
jobs: jobs:
axe-linter: axe-linter:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat' if: >
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_workflow == 'true')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dequelabs/axe-linter-action@v1 - uses: dequelabs/axe-linter-action@v1
with: with:
api_key: ${{ secrets.AXE_LINTER_API_KEY }} api_key: ${{ secrets.AXE_LINTER_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -110,7 +110,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
placeholder={localize('com_files_filter')} placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''} value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)} onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="max-w-sm dark:border-gray-500" className="max-w-sm border-border-light placeholder:text-text-secondary"
/> />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -175,7 +175,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
@ -184,7 +184,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
> >
{row.getVisibleCells().map((cell, index) => { {row.getVisibleCells().map((cell, index) => {
const maxWidth = const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ?? (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ??
'auto'; 'auto';
const style: Style = {}; const style: Style = {};

View file

@ -10,7 +10,7 @@ const Conversations = ({
moveToTop, moveToTop,
toggleNav, toggleNav,
}: { }: {
conversations: TConversation[]; conversations: Array<TConversation | null>;
moveToTop: () => void; moveToTop: () => void;
toggleNav: () => void; toggleNav: () => void;
}) => { }) => {
@ -33,15 +33,15 @@ const Conversations = ({
{groupedConversations.map(([groupName, convos]) => ( {groupedConversations.map(([groupName, convos]) => (
<div key={groupName}> <div key={groupName}>
<div <div
className="text-text-secondary"
style={{ style={{
color: '#535353',
fontSize: '0.7rem', fontSize: '0.7rem',
marginTop: '20px', marginTop: '20px',
marginBottom: '5px', marginBottom: '5px',
paddingLeft: '10px', paddingLeft: '10px',
}} }}
> >
{localize(groupName) || groupName} {localize(groupName) ?? groupName}
</div> </div>
{convos.map((convo, i) => ( {convos.map((convo, i) => (
<Convo <Convo

View file

@ -150,7 +150,7 @@ export default function DataTableFile<TData, TValue>({
placeholder={localize('com_files_filter')} placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''} value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)} onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="max-w-sm dark:border-gray-500" className="max-w-sm border-border-light placeholder:text-text-secondary"
/> />
<UploadFileButton <UploadFileButton
onClick={() => { onClick={() => {
@ -204,7 +204,7 @@ export default function DataTableFile<TData, TValue>({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
@ -213,7 +213,7 @@ export default function DataTableFile<TData, TValue>({
> >
{row.getVisibleCells().map((cell, index) => { {row.getVisibleCells().map((cell, index) => {
const maxWidth = const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ?? (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ??
'auto'; 'auto';
const style: Style = {}; const style: Style = {};

View file

@ -164,7 +164,7 @@ export default function FileSidePanel() {
onChange={() => { onChange={() => {
console.log('changed'); console.log('changed');
}} }}
className="max-w-sm dark:border-gray-500" className="max-w-sm border-border-light placeholder:text-text-secondary"
/> />
</div> </div>
<div className="w-1/3"> <div className="w-1/3">

View file

@ -232,7 +232,7 @@ export default function VectorStoreSidePanel() {
onChange={() => { onChange={() => {
console.log('changed'); console.log('changed');
}} }}
className="max-w-sm dark:border-gray-500" className="max-w-sm border-border-light placeholder:text-text-secondary"
/> />
</div> </div>
<div className="w-1/3"> <div className="w-1/3">

View file

@ -60,14 +60,16 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover dark:focus-within:bg-surface-hover', 'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
isSmallScreen === true ? 'h-16 rounded-2xl' : '', isSmallScreen === true ? 'h-16 rounded-2xl' : '',
)} )}
> >
{<Search className="absolute left-3 h-4 w-4" />} {
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
}
<input <input
type="text" type="text"
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 outline-none dark:placeholder-opacity-100" className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
value={text} value={text}
onChange={onChange} onChange={onChange}
onKeyDown={(e) => { onKeyDown={(e) => {

View file

@ -2,7 +2,6 @@ import { useState, useMemo } from 'react';
import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react'; import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react';
import type { TPromptGroup } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider';
import { import {
Button,
DropdownMenu, DropdownMenu,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuGroup, DropdownMenuGroup,
@ -29,12 +28,12 @@ export default function ChatGroupItem({
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 && group.projectIds?.includes(instanceProjectId), () => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
[group, instanceProjectId], [group, instanceProjectId],
); );
const isOwner = useMemo(() => user?.id === group.author, [user, group]); const isOwner = useMemo(() => user?.id === group.author, [user, group]);
const onCardClick = () => { const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
const text = group.productionPrompt?.prompt ?? ''; const text = group.productionPrompt?.prompt ?? '';
if (!text) { if (!text) {
return; return;
@ -53,23 +52,26 @@ export default function ChatGroupItem({
name={group.name} name={group.name}
category={group.category ?? ''} category={group.category ?? ''}
onClick={onCardClick} onClick={onCardClick}
snippet={group.oneliner ? group.oneliner : group.productionPrompt?.prompt ?? ''} snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: group.productionPrompt?.prompt ?? ''
}
> >
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
{groupIsGlobal && <EarthIcon className="icon-md text-green-400" />} {groupIsGlobal === true && <EarthIcon className="icon-md text-green-400" />}
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <button
id="promtps-menu-trigger" id="prompts-menu-trigger"
aria-label="promtps-menu-trigger" aria-label="prompts-menu-trigger"
variant="outline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
className="z-50 h-7 w-7 p-0 transition-all duration-300 ease-in-out hover:border-white dark:bg-gray-800 dark:hover:border-gray-400 dark:focus:border-gray-500" 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 dark:text-gray-300" /> <MenuIcon className="icon-md text-text-secondary" />
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="z-50 mt-2 w-36 rounded-lg" className="z-50 mt-2 w-36 rounded-lg"
@ -81,7 +83,7 @@ export default function ChatGroupItem({
e.stopPropagation(); e.stopPropagation();
setPreviewDialogOpen(true); setPreviewDialogOpen(true);
}} }}
className="w-full cursor-pointer rounded-lg disabled:cursor-not-allowed dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700" 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" />
<span>{localize('com_ui_preview')}</span> <span>{localize('com_ui_preview')}</span>
@ -90,7 +92,7 @@ export default function ChatGroupItem({
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
disabled={!isOwner} disabled={!isOwner}
className="cursor-pointer rounded-lg disabled:cursor-not-allowed dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700" className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEditClick(e); onEditClick(e);

View file

@ -152,7 +152,7 @@ export default function FilterPrompts({
setDisplayName(e.target.value); setDisplayName(e.target.value);
setName(e.target.value); setName(e.target.value);
}} }}
className="w-full border-border-light" className="w-full border-border-light placeholder:text-text-secondary"
/> />
</div> </div>
); );

View file

@ -10,27 +10,25 @@ export default function ListCard({
category: string; category: string;
name: string; name: string;
snippet: string; snippet: string;
onClick?: React.MouseEventHandler<HTMLDivElement>; onClick?: React.MouseEventHandler<HTMLButtonElement>;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
return ( return (
<div <button
onClick={onClick} onClick={onClick}
className="relative my-2 flex w-full cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top className="relative my-2 flex w-full cursor-pointer flex-col gap-2 rounded-2xl border border-border-light px-3 pb-4 pt-3 text-start
text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-all duration-300 ease-in-out hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-700" align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-all duration-300 ease-in-out hover:bg-surface-tertiary"
> >
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<CategoryIcon category={category} className="icon-md" /> <CategoryIcon category={category} className="icon-md" />
<h3 className="break-word select-none text-balance text-sm font-semibold text-gray-800 dark:text-gray-200"> <h3 className="break-word select-none text-balance text-sm font-semibold text-text-primary">
{name} {name}
</h3> </h3>
</div> </div>
<div>{children}</div> <div>{children}</div>
</div> </div>
<div className="ellipsis select-none text-balance text-sm text-gray-600 dark:text-gray-400"> <div className="ellipsis select-none text-balance text-sm text-text-secondary">{snippet}</div>
{snippet} </button>
</div>
</div>
); );
} }

View file

@ -50,7 +50,7 @@ const BookmarkTable = () => {
placeholder={localize('com_ui_bookmarks_filter')} placeholder={localize('com_ui_bookmarks_filter')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-border-light" className="w-full border-border-light placeholder:text-text-secondary"
/> />
</div> </div>
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10"> <div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">

View file

@ -271,7 +271,7 @@ export default function AssistantPanel({
name="id" name="id"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<p className="h-3 text-xs italic text-gray-600">{field.value ?? ''}</p> <p className="h-3 text-xs italic text-text-secondary">{field.value ?? ''}</p>
)} )}
/> />
</div> </div>
@ -310,7 +310,7 @@ export default function AssistantPanel({
{...field} {...field}
value={field.value ?? ''} value={field.value ?? ''}
{...{ max: 32768 }} {...{ max: 32768 }}
className={cn(inputClass, 'min-h-[100px] resize-none resize-y')} className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions" id="instructions"
placeholder={localize('com_assistants_instructions_placeholder')} placeholder={localize('com_assistants_instructions_placeholder')}
rows={3} rows={3}

View file

@ -45,7 +45,7 @@ export default function CodeFiles({
const endpointFileConfig = fileConfig.endpoints[endpoint]; const endpointFileConfig = fileConfig.endpoints[endpoint];
if (endpointFileConfig?.disabled) { if (endpointFileConfig.disabled) {
return null; return null;
} }
@ -60,7 +60,7 @@ export default function CodeFiles({
return ( return (
<div className="mb-2 w-full"> <div className="mb-2 w-full">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="text-token-text-tertiary rounded-lg text-xs"> <div className="rounded-lg text-xs text-text-secondary">
{localize('com_assistants_code_interpreter_files')} {localize('com_assistants_code_interpreter_files')}
</div> </div>
<FileRow <FileRow

View file

@ -77,7 +77,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
placeholder={localize('com_files_filter')} placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''} value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)} onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="w-full border-border-light" className="w-full border-border-light placeholder:text-text-secondary"
/> />
</div> </div>
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10"> <div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
@ -102,7 +102,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}

View file

@ -13,7 +13,6 @@ import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { import type {
TConversation, TConversation,
ConversationData, ConversationData,
ConversationUpdater,
GroupedConversations, GroupedConversations,
ConversationListResponse, ConversationListResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
@ -61,7 +60,9 @@ const getGroupName = (date: Date) => {
return ' ' + getYear(date).toString(); return ' ' + getYear(date).toString();
}; };
export const groupConversationsByDate = (conversations: TConversation[]): GroupedConversations => { export const groupConversationsByDate = (
conversations: Array<TConversation | null>,
): GroupedConversations => {
if (!Array.isArray(conversations)) { if (!Array.isArray(conversations)) {
return []; return [];
} }

View file

@ -60,13 +60,13 @@ export const cardStyle =
'transition-colors rounded-md min-w-[75px] border font-normal bg-white hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700 dark:bg-gray-800 text-black dark:text-gray-600 focus:outline-none data-[state=open]:bg-gray-50 dark:data-[state=open]:bg-gray-700'; 'transition-colors rounded-md min-w-[75px] border font-normal bg-white hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700 dark:bg-gray-800 text-black dark:text-gray-600 focus:outline-none data-[state=open]:bg-gray-50 dark:data-[state=open]:bg-gray-700';
export const defaultTextProps = export const defaultTextProps =
'rounded-md border border-gray-200 focus:border-gray-400 focus:bg-gray-50 bg-transparent text-sm shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:outline-none focus:ring-gray-400 focus:ring-opacity-20 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:focus:bg-gray-600 dark:focus:border-gray-600 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:outline-none'; 'rounded-md border border-gray-200 focus:border-gray-400 focus:bg-gray-50 bg-transparent text-sm shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none focus-within:placeholder:text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-gray-400 focus:ring-opacity-20 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:focus:bg-gray-600 dark:focus:border-gray-600 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:outline-none';
export const optionText = export const optionText =
'p-0 shadow-none text-right pr-1 h-8 border-transparent hover:bg-gray-800/10 dark:hover:bg-white/10 dark:focus:bg-white/10 transition-colors'; 'p-0 shadow-none text-right pr-1 h-8 border-transparent hover:bg-gray-800/10 dark:hover:bg-white/10 dark:focus:bg-white/10 transition-colors';
export const defaultTextPropsLabel = export const defaultTextPropsLabel =
'rounded-md border border-gray-300 bg-transparent text-sm shadow-[0_0_10px_rgba(0,0,0,0.10)] outline-none placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-600 dark:focus:outline-none'; 'rounded-md border border-gray-300 bg-transparent text-sm shadow-[0_0_10px_rgba(0,0,0,0.10)] outline-none focus-within:placeholder:text-text-primary placeholder:text-text-secondary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-600 dark:focus:outline-none';
export function capitalizeFirstLetter(string: string) { export function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1); return string.charAt(0).toUpperCase() + string.slice(1);