LibreChat/packages/client/src/components/Badge.tsx
Daniel Lew 1143f73f59
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
🔇 fix: Hide Button Icons from Screen Readers (#10776)
If you've got a screen reader that is reading out the whole page,
each icon button (i.e., `<button><SVG></button>`) will have both
the button's aria-label read out as well as the title from the
SVG (which is usually just "image").

Since we are pretty good about setting aria-labels, we should instead
use `aria-hidden="true"` on these images, since they are not useful
to be read out.

I don't consider this a comprehensive review of all icons in the app,
but I knocked out all the low hanging fruit in this commit.
2025-12-11 16:35:17 -05:00

118 lines
3.3 KiB
TypeScript

import type React from 'react';
import { X, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
import type { ButtonHTMLAttributes } from 'react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '~/utils';
interface BadgeProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'
> {
icon?: LucideIcon;
label: string;
id?: string;
isActive?: boolean;
isEditing?: boolean;
isDragging?: boolean;
isAvailable: boolean;
isInChat?: boolean;
onBadgeAction?: () => void;
onToggle?: () => void;
}
export default function Badge({
icon: Icon,
label,
id,
isActive = false,
isEditing = false,
isDragging = false,
isAvailable = true,
isInChat = false,
onBadgeAction,
onToggle,
className,
...props
}: BadgeProps) {
const isMoveable = isEditing && isAvailable;
const isDisabled = id === '1' && isInChat;
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!isEditing && onToggle) {
e.preventDefault();
e.stopPropagation();
onToggle();
}
};
const getWhileTapScale = () => {
if (isDragging) {
return 1.1;
}
if (isDisabled) {
return 1;
}
return 0.97;
};
return (
<motion.button
onClick={handleClick}
className={cn(
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
'border border-border-medium text-sm font-medium transition-shadow',
'@container-[600px]:w-full size-9 p-2',
isActive
? 'bg-surface-active shadow-md'
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
isMoveable && 'cursor-move',
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
className,
)}
animate={{
scale: isDragging ? 1.1 : 1,
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
}}
whileTap={{ scale: getWhileTapScale() }}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
{...(props as React.ComponentProps<typeof motion.button>)}
>
{Icon && (
<Icon
className={cn(
'@container-[600px]:h-4 @container-[600px]:w-4 relative h-5 w-5',
!label && 'mx-auto',
)}
aria-hidden="true"
/>
)}
<span className="@container-[600px]:inline relative hidden">{label}</span>
{isEditing && !isDragging && (
<motion.button
className="@container-[600px]:h-5 @container-[600px]:w-5 absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
whileTap={{ scale: 0.9 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onBadgeAction?.();
}}
>
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</motion.button>
)}
</motion.button>
);
}