Skip to content
Merged
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
6 changes: 4 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4178,7 +4178,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,
});
});
};
const scrollToEndStable = useStableCallback(scrollToEnd);
Expand Down Expand Up @@ -4383,7 +4385,7 @@ export default function ChatView(props: ChatViewProps) {
scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true });
void list.scrollToIndex({
index: anchorIndex,
animated: true,
animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
viewPosition: 0,
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
});
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 @@ -1510,7 +1510,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
viewOffset: 24,
});
}}
Expand Down
68 changes: 56 additions & 12 deletions apps/web/src/components/chat/pageScrollController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class TestClock {
private animationFrames = new Map<number, FrameRequestCallback>();
private timeouts = new Map<number, { at: number; callback: () => void }>();

reducedMotion = false;

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

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

test("continues scrolling on hold without repeated keydown events and stops on keyup", () => {
test("uses the current motion preference for page taps in both directions", () => {
const clock = new TestClock();
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
scrollTop: 1_200,
getBoundingClientRect: () => ({ height: 600 }),
};
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
env: clock.env,
});
controller.handleKeyDown("PageDown");
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50);

const afterHoldStarts = container.scrollTop;
clock.advanceBy(200);
for (const reducedMotion of [false, true, false]) {
clock.reducedMotion = reducedMotion;
for (const [key, destination] of [
["PageDown", 1_740],
["PageUp", 1_200],
] as const) {
const start = container.scrollTop;
controller.handleKeyDown(key);
controller.handleKeyUp(key);

if (!reducedMotion) {
clock.advanceBy(16);
expect(container.scrollTop).toBeGreaterThan(Math.min(start, destination));
expect(container.scrollTop).toBeLessThan(Math.max(start, destination));
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
}
expect(container.scrollTop).toBeCloseTo(destination, 5);
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2);
expect(container.scrollTop).toBeCloseTo(destination, 5);
}
}
});

test.each([false, true])(
"continues scrolling on hold without repeated keydown events and stops on keyup (reduced motion: %s)",
(reducedMotion) => {
const clock = new TestClock();
clock.reducedMotion = reducedMotion;
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
env: clock.env,
});
controller.handleKeyDown("PageDown");
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50);

expect(container.scrollTop).toBeGreaterThan(afterHoldStarts);
const afterHoldStarts = container.scrollTop;
clock.advanceBy(200);

const stoppedAt = container.scrollTop;
controller.handleKeyUp("PageDown");
clock.advanceBy(250);
expect(container.scrollTop).toBeGreaterThan(afterHoldStarts);

expect(container.scrollTop).toBe(stoppedAt);
});
const stoppedAt = container.scrollTop;
controller.handleKeyUp("PageDown");
clock.advanceBy(250);

expect(container.scrollTop).toBe(stoppedAt);
},
);

test("notifies once when a page scroll starts", () => {
const clock = new TestClock();
Expand Down
11 changes: 9 additions & 2 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 @@ -206,9 +208,14 @@ export function createPageScrollController({
state.holdActive = false;
};

const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => {
const scrollBy = (container: PageScrollContainer, deltaY: number) => {
cancelDiscreteAnimation();

if (env.prefersReducedMotion()) {
container.scrollTop += deltaY;
return;
}

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

Expand Down Expand Up @@ -279,7 +286,7 @@ export function createPageScrollController({
state.activeKey = key;
onScrollStart?.(key);

smoothScrollBy(container, readPageScrollDistance(container) * getDirection(key));
scrollBy(container, readPageScrollDistance(container) * getDirection(key));

state.holdDelayTimeout = env.setTimeout(() => {
state.holdDelayTimeout = 0;
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 @@ -504,9 +504,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