♿ fix: Honor reduced motion on manual phase disclosure

The entrance already respected the preference, but manually opening or
closing a phase did not: `useExpandCollapse` writes its transition as an
inline style, which cannot carry a `prefers-reduced-motion` media query,
and there is no global reduced-motion reset in the stylesheet. Before this
PR the phase used `<details>`, which had no animation at all — so the swap
to an animated disclosure handed reduced-motion readers a 300ms fold they
did not have.

Resolve the preference in the hook and drop the transition outright. Every
expanding panel in the message content shares it, so tool calls, thinking
blocks, attachments and web-search sources are covered by the same change.
The chevron and the fold's own utility classes get `motion-reduce`
overrides, which the inline styles cannot express.
This commit is contained in:
Danny Avila 2026-08-14 11:23:11 -04:00
parent 7dc8c06934
commit 3b9bd2181d
3 changed files with 66 additions and 4 deletions

View file

@ -18,7 +18,7 @@ import { cn } from '~/utils';
/** Matches `EXPAND_TRANSITION` so the header, the panel, and the card chrome
* all resolve on the same curve — three properties animating on two different
* easings is what makes a fold read as two separate movements. */
const FOLD_EASING = 'duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]';
const FOLD_EASING = 'duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none';
type ActivityPhasePart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }> & {
activity_label_type?: 'phase';
@ -200,7 +200,7 @@ export default function ActivityPhaseGroup({
</span>
<ChevronDown
className={cn(
'size-4 shrink-0 transition-transform duration-200 ease-out',
'size-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none',
isExpanded && 'rotate-180',
)}
aria-hidden="true"

View file

@ -0,0 +1,54 @@
import { renderHook } from '@testing-library/react';
import useExpandCollapse, { EXPAND_TRANSITION } from '../useExpandCollapse';
function stubReducedMotion(reduce: boolean) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion') ? reduce : false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})) as unknown as typeof window.matchMedia;
}
describe('useExpandCollapse', () => {
const original = window.matchMedia;
afterEach(() => {
window.matchMedia = original;
});
test('animates the disclosure by default', () => {
stubReducedMotion(false);
const { result } = renderHook(() => useExpandCollapse(true));
expect(result.current.style.transition).toBe(EXPAND_TRANSITION);
expect(result.current.style.gridTemplateRows).toBe('1fr');
expect(result.current.style.opacity).toBe(1);
});
/** The transition is an inline style, so it cannot carry a media query.
* Without this the panel still folds over 300ms for a reader who asked
* the platform for no motion. */
test('drops the transition under prefers-reduced-motion', () => {
stubReducedMotion(true);
const { result } = renderHook(() => useExpandCollapse(true));
expect(result.current.style.transition).toBe('none');
});
test('still collapses to a zero row under reduced motion', () => {
stubReducedMotion(true);
const { result } = renderHook(() => useExpandCollapse(false));
expect(result.current.style.transition).toBe('none');
expect(result.current.style.gridTemplateRows).toBe('0fr');
expect(result.current.style.opacity).toBe(0);
});
});

View file

@ -1,14 +1,22 @@
import { useRef, useLayoutEffect, useMemo } from 'react';
import { useMediaQuery } from '@librechat/client';
import type { CSSProperties } from 'react';
export const EXPAND_TRANSITION =
'grid-template-rows 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
/**
* The disclosure motion is an inline style, so it cannot carry a
* `prefers-reduced-motion` media query the way a utility class can. Resolve
* the preference here instead and drop the transition outright — every
* expanding panel in the message content shares this hook.
*/
export default function useExpandCollapse(isExpanded: boolean): {
style: CSSProperties;
ref: React.RefObject<HTMLDivElement>;
} {
const ref = useRef<HTMLDivElement>(null);
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
useLayoutEffect(() => {
const el = ref.current;
@ -27,10 +35,10 @@ export default function useExpandCollapse(isExpanded: boolean): {
() => ({
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
transition: EXPAND_TRANSITION,
transition: reducedMotion ? 'none' : EXPAND_TRANSITION,
opacity: isExpanded ? 1 : 0,
}),
[isExpanded],
[isExpanded, reducedMotion],
);
return { style, ref };