🤲 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:
paths:
- 'client/src/**'
workflow_dispatch:
inputs:
run_workflow:
description: 'Set to true to run this workflow'
required: true
default: 'false'
jobs:
axe-linter:
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:
- uses: actions/checkout@v4
- uses: dequelabs/axe-linter-action@v1
with:
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')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
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>
<DropdownMenuTrigger asChild>
@ -175,7 +175,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@ -184,7 +184,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
>
{row.getVisibleCells().map((cell, index) => {
const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ??
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ??
'auto';
const style: Style = {};

View file

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

View file

@ -150,7 +150,7 @@ export default function DataTableFile<TData, TValue>({
placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
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
onClick={() => {
@ -204,7 +204,7 @@ export default function DataTableFile<TData, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@ -213,7 +213,7 @@ export default function DataTableFile<TData, TValue>({
>
{row.getVisibleCells().map((cell, index) => {
const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ??
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ??
'auto';
const style: Style = {};

View file

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

View file

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

View file

@ -60,14 +60,16 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
<div
ref={ref}
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' : '',
)}
>
{<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
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}
onChange={onChange}
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 type { TPromptGroup } from 'librechat-data-provider';
import {
Button,
DropdownMenu,
DropdownMenuItem,
DropdownMenuGroup,
@ -29,12 +28,12 @@ export default function ChatGroupItem({
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const onEditClick = useCustomLink<HTMLDivElement>(`/d/prompts/${group._id}`);
const groupIsGlobal = useMemo(
() => instanceProjectId && group.projectIds?.includes(instanceProjectId),
() => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
[group, instanceProjectId],
);
const isOwner = useMemo(() => user?.id === group.author, [user, group]);
const onCardClick = () => {
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
const text = group.productionPrompt?.prompt ?? '';
if (!text) {
return;
@ -53,23 +52,26 @@ export default function ChatGroupItem({
name={group.name}
category={group.category ?? ''}
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">
{groupIsGlobal && <EarthIcon className="icon-md text-green-400" />}
{groupIsGlobal === true && <EarthIcon className="icon-md text-green-400" />}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
id="promtps-menu-trigger"
aria-label="promtps-menu-trigger"
variant="outline"
<button
id="prompts-menu-trigger"
aria-label="prompts-menu-trigger"
onClick={(e) => {
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" />
</Button>
<MenuIcon className="icon-md text-text-secondary" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-50 mt-2 w-36 rounded-lg"
@ -81,7 +83,7 @@ export default function ChatGroupItem({
e.stopPropagation();
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" />
<span>{localize('com_ui_preview')}</span>
@ -90,7 +92,7 @@ export default function ChatGroupItem({
<DropdownMenuGroup>
<DropdownMenuItem
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) => {
e.stopPropagation();
onEditClick(e);

View file

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

View file

@ -10,27 +10,25 @@ export default function ListCard({
category: string;
name: string;
snippet: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
children?: React.ReactNode;
}) {
return (
<div
<button
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
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"
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
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 flex-row gap-2">
<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}
</h3>
</div>
<div>{children}</div>
</div>
<div className="ellipsis select-none text-balance text-sm text-gray-600 dark:text-gray-400">
{snippet}
</div>
</div>
<div className="ellipsis select-none text-balance text-sm text-text-secondary">{snippet}</div>
</button>
);
}

View file

@ -50,7 +50,7 @@ const BookmarkTable = () => {
placeholder={localize('com_ui_bookmarks_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-border-light"
className="w-full border-border-light placeholder:text-text-secondary"
/>
</div>
<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"
control={control}
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>
@ -310,7 +310,7 @@ export default function AssistantPanel({
{...field}
value={field.value ?? ''}
{...{ max: 32768 }}
className={cn(inputClass, 'min-h-[100px] resize-none resize-y')}
className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions"
placeholder={localize('com_assistants_instructions_placeholder')}
rows={3}

View file

@ -45,7 +45,7 @@ export default function CodeFiles({
const endpointFileConfig = fileConfig.endpoints[endpoint];
if (endpointFileConfig?.disabled) {
if (endpointFileConfig.disabled) {
return null;
}
@ -60,7 +60,7 @@ export default function CodeFiles({
return (
<div className="mb-2 w-full">
<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')}
</div>
<FileRow

View file

@ -77,7 +77,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
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 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>
<TableBody>
{table.getRowModel().rows?.length ? (
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}

View file

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