Skip to content

Commit 5558575

Browse files
RealDiligentRealDiligent
andauthored
fix(ui-kit): respect prefers-reduced-motion across animated ui-kit and miner-ui components (#8360)
13 ui-kit components (accordion, alert-dialog, context-menu, dialog, dropdown-menu, hover-card, input-otp, menubar, navigation-menu, popover, select, sheet, tooltip) and 2 miner-ui components (streaming-text, chat/typing-indicator) animated unconditionally, so a user with the OS reduce-motion preference still got full fade/zoom/slide/bounce/blink/caret motion. Pairs each animate-* utility with motion-reduce:animate-none, the same guard skeleton.tsx/state-views.tsx/button.tsx/tabs.tsx already apply, inline at each call site (no new abstraction). Adds regression tests asserting the guard on a Radix content component (AccordionContent) and on TypingIndicator, plus a class-list assertion on StreamingText's caret. Closes #8303 Co-authored-by: RealDiligent <nft.gold.eth@gmail.com>
1 parent ee7eed6 commit 5558575

18 files changed

Lines changed: 87 additions & 25 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { TypingIndicator } from "./typing-indicator";
5+
6+
// Regression for #8303: the three animate-bounce dots must each pair their animation with
7+
// motion-reduce:animate-none so a user with the OS "reduce motion" preference set sees a static indicator
8+
// instead of a bouncing one -- matching the guard skeleton.tsx / Spinner already provide.
9+
describe("TypingIndicator respects prefers-reduced-motion (#8303)", () => {
10+
it("renders three bouncing dots that each carry motion-reduce:animate-none", () => {
11+
const { container } = render(<TypingIndicator authorName="Assistant" />);
12+
const dots = container.querySelectorAll(".animate-bounce");
13+
expect(dots.length).toBe(3);
14+
for (const dot of dots) {
15+
expect(dot.className).toContain("motion-reduce:animate-none");
16+
}
17+
});
18+
19+
it("renders nothing when not composing (unchanged behavior)", () => {
20+
const { container } = render(<TypingIndicator composing={false} />);
21+
expect(container.firstChild).toBeNull();
22+
});
23+
});

apps/loopover-miner-ui/src/components/chat/typing-indicator.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ export function TypingIndicator({
1616
<span className="sr-only">{label}</span>
1717
<span
1818
aria-hidden="true"
19-
className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.3s]"
19+
className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.3s] motion-reduce:animate-none"
2020
/>
2121
<span
2222
aria-hidden="true"
23-
className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.15s]"
23+
className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.15s] motion-reduce:animate-none"
24+
/>
25+
<span
26+
aria-hidden="true"
27+
className="size-1.5 animate-bounce rounded-full bg-muted-foreground motion-reduce:animate-none"
2428
/>
25-
<span aria-hidden="true" className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
2629
</div>
2730
);
2831
}

apps/loopover-miner-ui/src/components/streaming-text.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function StreamingText({ source, className }: { source: ChunkSource | nul
3232
<p className={className} data-status={status} aria-busy={status === "streaming"}>
3333
{text}
3434
{status === "streaming" && !reducedMotion ? (
35-
<span aria-hidden="true" className="ml-0.5 inline-block animate-pulse">
35+
<span aria-hidden="true" className="ml-0.5 inline-block animate-pulse motion-reduce:animate-none">
3636
3737
</span>
3838
) : null}

apps/loopover-miner-ui/src/streaming-text.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ describe("StreamingText (#6516)", () => {
6161
await src.push("typing…");
6262
await waitFor(() => expect(screen.getByText(/typing/)).toBeTruthy());
6363
expect(caret()).not.toBeNull(); // still streaming → caret present under full motion
64+
// #8303: the caret's animate-pulse is also paired with motion-reduce:animate-none as a CSS-level
65+
// fallback, complementing the JS usePrefersReducedMotion guard that already omits it entirely.
66+
expect(caret()?.className).toContain("motion-reduce:animate-none");
6467
});
6568

6669
it("suppresses the caret under prefers-reduced-motion but still reaches the full text and done", async () => {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import {
5+
Accordion,
6+
AccordionContent,
7+
AccordionItem,
8+
AccordionTrigger,
9+
} from "./accordion";
10+
11+
// Regression for #8303: the Radix content/transition components in @loopover/ui-kit must pair their
12+
// animate-* utilities with motion-reduce:animate-none, matching skeleton.tsx / state-views.tsx, so a user
13+
// with the OS "reduce motion" preference set does not get the fade/zoom/slide/accordion animation.
14+
// AccordionContent is a representative Radix content component (its animate-accordion-up/down utilities are
15+
// the ones the issue calls out) and renders inline when its item is open.
16+
describe("AccordionContent respects prefers-reduced-motion (#8303)", () => {
17+
it("carries motion-reduce:animate-none alongside its animate-accordion utilities", () => {
18+
const { getByText } = render(
19+
<Accordion type="single" defaultValue="a" collapsible>
20+
<AccordionItem value="a">
21+
<AccordionTrigger>Trigger</AccordionTrigger>
22+
<AccordionContent>Body</AccordionContent>
23+
</AccordionItem>
24+
</Accordion>,
25+
);
26+
// The Radix Content wraps the inner padding div that holds the text.
27+
const content = getByText("Body").parentElement as HTMLElement;
28+
expect(content.className).toContain(
29+
"data-[state=open]:animate-accordion-down",
30+
);
31+
expect(content.className).toContain("motion-reduce:animate-none");
32+
});
33+
});

packages/loopover-ui-kit/src/components/accordion.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const AccordionContent = React.forwardRef<
4444
>(({ className, children, ...props }, ref) => (
4545
<AccordionPrimitive.Content
4646
ref={ref}
47-
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
47+
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down motion-reduce:animate-none"
4848
{...props}
4949
>
5050
<div className={cn("pb-4 pt-0", className)}>{children}</div>

packages/loopover-ui-kit/src/components/alert-dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
1616
>(({ className, ...props }, ref) => (
1717
<AlertDialogPrimitive.Overlay
1818
className={cn(
19-
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
19+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none",
2020
className,
2121
)}
2222
{...props}
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
3434
<AlertDialogPrimitive.Content
3535
ref={ref}
3636
className={cn(
37-
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
37+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg motion-reduce:animate-none",
3838
className,
3939
)}
4040
{...props}

packages/loopover-ui-kit/src/components/context-menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const ContextMenuSubContent = React.forwardRef<
4444
<ContextMenuPrimitive.SubContent
4545
ref={ref}
4646
className={cn(
47-
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
47+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin) motion-reduce:animate-none",
4848
className,
4949
)}
5050
{...props}
@@ -60,7 +60,7 @@ const ContextMenuContent = React.forwardRef<
6060
<ContextMenuPrimitive.Content
6161
ref={ref}
6262
className={cn(
63-
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
63+
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin) motion-reduce:animate-none",
6464
className,
6565
)}
6666
{...props}

packages/loopover-ui-kit/src/components/dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
2121
<DialogPrimitive.Overlay
2222
ref={ref}
2323
className={cn(
24-
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none",
2525
className,
2626
)}
2727
{...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
3838
<DialogPrimitive.Content
3939
ref={ref}
4040
className={cn(
41-
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
41+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg motion-reduce:animate-none",
4242
className,
4343
)}
4444
{...props}

packages/loopover-ui-kit/src/components/dropdown-menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
4747
<DropdownMenuPrimitive.SubContent
4848
ref={ref}
4949
className={cn(
50-
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
50+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin) motion-reduce:animate-none",
5151
className,
5252
)}
5353
{...props}
@@ -66,7 +66,7 @@ const DropdownMenuContent = React.forwardRef<
6666
sideOffset={sideOffset}
6767
className={cn(
6868
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
69-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
69+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin) motion-reduce:animate-none",
7070
className,
7171
)}
7272
{...props}

0 commit comments

Comments
 (0)