Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6240,7 +6240,9 @@ export default function ChatView(props: ChatViewProps) {
// The anchored end space must be gone before the scroll measures, or the
// list lands short of the real end (#6519).
requestAnimationFrame(() => {
void legendListRef.current?.scrollToEnd?.({ animated });
void legendListRef.current?.scrollToEnd?.({
animated: animated && !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
});
});
}, []);
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1389,7 +1389,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
animated: !prefersReducedMotion,
viewOffset: 24,
});
}}
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/components/chat/pageScrollController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class TestClock {
private nextHandle = 1;
private animationFrames = new Map<number, FrameRequestCallback>();
private timeouts = new Map<number, { at: number; callback: () => void }>();
reducedMotion = false;

readonly env = {
now: () => this.currentTime,
Expand All @@ -37,6 +38,7 @@ class TestClock {
clearTimeout: (handle: number) => {
this.timeouts.delete(handle);
},
prefersReducedMotion: () => this.reducedMotion,
};

advanceBy(ms: number, frameMs = 16) {
Expand Down Expand Up @@ -201,6 +203,67 @@ describe("createPageScrollController", () => {
);
});

test("moves a full page without animation frames when motion is reduced", () => {
const clock = new TestClock();
clock.reducedMotion = true;
const container = {
clientHeight: 600,
scrollHeight: 1_800,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
env: clock.env,
});
const pageDistance = getPageScrollDistancePx({
containerHeightPx: 600,
scrollPaddingBottomPx: 24,
});

controller.handleKeyDown("PageDown");

expect(container.scrollTop).toBe(pageDistance);

controller.handleKeyUp("PageDown");
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);

expect(container.scrollTop).toBe(pageDistance);
});

test("reads the motion preference on each press", () => {
const clock = new TestClock();
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
env: clock.env,
});
const pageDistance = getPageScrollDistancePx({
containerHeightPx: 600,
scrollPaddingBottomPx: 24,
});

controller.handleKeyDown("PageDown");
controller.handleKeyUp("PageDown");
clock.advanceBy(16);

expect(container.scrollTop).toBeGreaterThan(0);
expect(container.scrollTop).toBeLessThan(pageDistance);

clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
clock.reducedMotion = true;
controller.handleKeyDown("PageDown");

expect(container.scrollTop).toBeCloseTo(pageDistance * 2, 5);
});

test("continues scrolling on hold without repeated keydown events and stops on keyup", () => {
const clock = new TestClock();
const container = {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/pageScrollController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type PageScrollEnv = {
cancelAnimationFrame: (handle: number) => void;
setTimeout: (callback: () => void, delay: number) => number;
clearTimeout: (handle: number) => void;
prefersReducedMotion: () => boolean;
};

function getDefaultEnv(): PageScrollEnv {
Expand All @@ -90,6 +91,7 @@ function getDefaultEnv(): PageScrollEnv {
cancelAnimationFrame: (handle) => window.cancelAnimationFrame(handle),
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
clearTimeout: (handle) => window.clearTimeout(handle),
prefersReducedMotion: () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
};
}

Expand Down Expand Up @@ -209,6 +211,12 @@ export function createPageScrollController({
const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => {
cancelDiscreteAnimation();

// Read per press so a preference change applies without a reload.
if (env.prefersReducedMotion()) {
container.scrollTop += deltaY;
return;
}

const startScrollTop = container.scrollTop;
const startTime = env.now();

Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/settings/ThemeEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,10 @@ export function ThemeEditorPanel({
if (!reveal) return;

requestAnimationFrame(() => {
panelRef.current
?.querySelector(`[data-theme-color-role="${visibleRole}"]`)
?.scrollIntoView({ behavior: "smooth", block: "nearest" });
panelRef.current?.querySelector(`[data-theme-color-role="${visibleRole}"]`)?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "nearest",
});
});
}, []);

Expand Down
Loading