a11y: Hide Collapsed Thinking Content From Screen Readers (#11927)

* fix(a11y): hide collapsed thinking content from screen readers and link toggle to controlled region

The thinking/reasoning toggle button visually collapsed content using a CSS
grid animation (gridTemplateRows: 0fr + overflow-hidden), but the content
remained in the DOM and fully accessible to screen readers, cluttering the
reading flow for assistive technology users.

- Add aria-hidden={!isExpanded} to the collapsible content region in both
  the legacy Thinking component and the modern Reasoning component, so
  screen readers skip collapsed thoughts entirely
- Add role="region" and a unique id (via useId) to each collapsible content
  div, giving it a semantic landmark for assistive technology
- Add contentId prop to the shared ThinkingButton and wire it to
  aria-controls on the toggle button, establishing an explicit relationship
  between the button and the region it expands/collapses
- aria-expanded was already present on the button; combined with
  aria-controls, screen readers can now fully convey the toggle state and
  its target

* fix(a11y): add aria-label to collapsible content regions in Thinking and Reasoning components

Enhanced accessibility by adding aria-label attributes to the collapsible content regions in both the Thinking and Reasoning components. This change ensures that screen readers can provide better context for users navigating through the content.

* fix(a11y): update roles and aria attributes in Thinking and Reasoning components

Changed role from "region" to "group" for collapsible content areas in both Thinking and Reasoning components to better align with ARIA practices. Updated aria-hidden to handle undefined values correctly and ensured contentId is passed to relevant components for improved accessibility and screen reader support.
This commit is contained in:
Danny Avila 2026-02-24 20:59:56 -05:00 committed by GitHub
parent 8c3c326440
commit 44dbbd5328
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 24 additions and 3 deletions

View file

@ -1,4 +1,4 @@
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { memo, useMemo, useState, useCallback, useRef, useId } from 'react';
import { useAtom } from 'jotai';
import type { MouseEvent, FocusEvent } from 'react';
import { ContentTypes } from 'librechat-data-provider';
@ -36,6 +36,7 @@ type ReasoningProps = {
* For legacy text-based messages, see Thinking.tsx component.
*/
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const contentId = useId();
const localize = useLocalize();
const [showThinking] = useAtom(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
@ -104,9 +105,14 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
onClick={handleClick}
label={label}
content={reasoningText}
contentId={contentId}
/>
</div>
<div
id={contentId}
role="group"
aria-label={label}
aria-hidden={!isExpanded || undefined}
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
@ -122,6 +128,7 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
isExpanded={isExpanded}
onClick={handleClick}
content={reasoningText}
contentId={contentId}
/>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { useState, useMemo, memo, useCallback, useRef, type MouseEvent } from 'react';
import { useState, useMemo, memo, useCallback, useRef, useId, type MouseEvent } from 'react';
import { useAtomValue } from 'jotai';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { Lightbulb, ChevronDown, ChevronUp } from 'lucide-react';
@ -35,12 +35,14 @@ export const ThinkingButton = memo(
onClick,
label,
content,
contentId,
showCopyButton = true,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string;
content?: string;
contentId: string;
showCopyButton?: boolean;
}) => {
const localize = useLocalize();
@ -66,6 +68,7 @@ export const ThinkingButton = memo(
type="button"
onClick={onClick}
aria-expanded={isExpanded}
aria-controls={contentId}
className={cn(
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
fontSize,
@ -132,11 +135,13 @@ export const FloatingThinkingBar = memo(
isExpanded,
onClick,
content,
contentId,
}: {
isVisible: boolean;
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
content?: string;
contentId: string;
}) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
@ -176,6 +181,8 @@ export const FloatingThinkingBar = memo(
tabIndex={isVisible ? 0 : -1}
onClick={onClick}
aria-label={collapseTooltip}
aria-expanded={isExpanded}
aria-controls={contentId}
className={cn(
'flex items-center justify-center rounded-lg bg-surface-secondary p-1.5 text-text-secondary-alt shadow-sm',
'hover:bg-surface-hover hover:text-text-primary',
@ -240,6 +247,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
const [isExpanded, setIsExpanded] = useState(showThinking);
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const contentId = useId();
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
@ -295,9 +303,14 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
onClick={handleClick}
label={label}
content={textContent}
contentId={contentId}
/>
</div>
<div
id={contentId}
role="group"
aria-label={label}
aria-hidden={!isExpanded || undefined}
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
@ -310,6 +323,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
isExpanded={isExpanded}
onClick={handleClick}
content={textContent}
contentId={contentId}
/>
</div>
</div>
@ -322,4 +336,4 @@ ThinkingContent.displayName = 'ThinkingContent';
FloatingThinkingBar.displayName = 'FloatingThinkingBar';
Thinking.displayName = 'Thinking';
export default memo(Thinking);
export default Thinking;