mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: scroll without gliding when reduce motion is on
This commit is contained in:
@@ -103,6 +103,7 @@ import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
import { scrollBehavior } from '@/lib/reduced-motion';
|
||||
|
||||
type Tab = SettingsSearchTab;
|
||||
|
||||
@@ -602,7 +603,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
}
|
||||
return;
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.scrollIntoView({ behavior: scrollBehavior(), block: 'center' });
|
||||
// Remove + reflow + add restarts the CSS animation if the class was
|
||||
// already present (re-clicking the same sub-result).
|
||||
el.classList.remove('settings-search-highlight');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Button, TOUCH_HIT_AREA } from '@/components/ui/button';
|
||||
import { TermInfo, type GlossaryId } from '@/components/ui/term';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { scrollBehavior } from '@/lib/reduced-motion';
|
||||
|
||||
/**
|
||||
* The Advanced switch. Rows and sections marked `advanced` render nothing
|
||||
@@ -106,7 +107,7 @@ const highlightTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>(
|
||||
export function scrollToSettingsSection(id: string, root: ParentNode = document): boolean {
|
||||
const el = root.querySelector<HTMLElement>(`[data-settings-section="${id.replace(/"/g, '\\"')}"]`);
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
el.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
|
||||
// Remove + reflow + add restarts the animation on a second click.
|
||||
clearTimeout(highlightTimers.get(el));
|
||||
el.classList.remove('settings-search-highlight');
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { findTourTarget, isTourStepOnPage } from "./tour-steps";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { prefersReducedMotion, scrollBehavior } from "@/lib/reduced-motion";
|
||||
|
||||
export { findTourTarget };
|
||||
|
||||
@@ -173,7 +174,7 @@ export function TourOverlay() {
|
||||
if (!onPageRef.current) return false;
|
||||
const el = findTourTarget(step.target);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
el.scrollIntoView({ behavior: scrollBehavior(), block: "nearest" });
|
||||
// Delay after scroll for layout to settle
|
||||
pendingTimerRef.current = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
@@ -292,9 +293,7 @@ export function TourOverlay() {
|
||||
const isFirst = currentStep === 0;
|
||||
const isInteractive = step.interactive;
|
||||
|
||||
const reducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const reducedMotion = typeof window !== "undefined" && prefersReducedMotion();
|
||||
|
||||
const transitionStyle = reducedMotion ? "none" : "all 300ms ease";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { scrollBehavior } from "@/lib/reduced-motion";
|
||||
|
||||
export type ScrollAxis = "vertical" | "horizontal";
|
||||
export type ScrollWindowSide = "start" | "end";
|
||||
@@ -19,13 +20,13 @@ export function setScrollStart(el: HTMLElement, axis: ScrollAxis, value: number)
|
||||
el.scrollLeft = getComputedStyle(el).direction === "rtl" ? -value : value;
|
||||
}
|
||||
|
||||
/** Smoothly scrolls to a logical start offset (RTL-aware). */
|
||||
/** Smoothly scrolls to a logical start offset (RTL-aware), at once when motion is reduced. */
|
||||
export function scrollToStart(el: HTMLElement, axis: ScrollAxis, value: number): void {
|
||||
if (axis === "vertical") {
|
||||
el.scrollTo({ top: value, behavior: "smooth" });
|
||||
el.scrollTo({ top: value, behavior: scrollBehavior() });
|
||||
return;
|
||||
}
|
||||
el.scrollTo({ left: getComputedStyle(el).direction === "rtl" ? -value : value, behavior: "smooth" });
|
||||
el.scrollTo({ left: getComputedStyle(el).direction === "rtl" ? -value : value, behavior: scrollBehavior() });
|
||||
}
|
||||
|
||||
function scrollSize(el: HTMLElement, axis: ScrollAxis): number {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { prefersReducedMotion, scrollBehavior } from '../reduced-motion';
|
||||
import { scrollToSettingsSection } from '@/components/settings/settings-section';
|
||||
import { scrollToStart } from '@/hooks/use-scroll-window';
|
||||
|
||||
function systemPrefers(reduce: boolean) {
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
matches: reduce && query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('data-motion');
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('prefersReducedMotion', () => {
|
||||
it('follows the operating system while the setting is System', () => {
|
||||
systemPrefers(true);
|
||||
expect(prefersReducedMotion()).toBe(true);
|
||||
systemPrefers(false);
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
});
|
||||
|
||||
it('reduces motion when the setting is On, whatever the system says', () => {
|
||||
systemPrefers(false);
|
||||
document.documentElement.setAttribute('data-motion', 'reduce');
|
||||
expect(prefersReducedMotion()).toBe(true);
|
||||
expect(scrollBehavior()).toBe('auto');
|
||||
});
|
||||
|
||||
it('keeps motion when the setting is Off, whatever the system says', () => {
|
||||
systemPrefers(true);
|
||||
document.documentElement.setAttribute('data-motion', 'full');
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
expect(scrollBehavior()).toBe('smooth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripted scrolling', () => {
|
||||
it('jumps to a settings section instead of gliding when motion is reduced', () => {
|
||||
systemPrefers(true);
|
||||
document.body.innerHTML = '<div data-settings-section="fonts"></div>';
|
||||
const el = document.querySelector<HTMLElement>('[data-settings-section="fonts"]')!;
|
||||
el.scrollIntoView = vi.fn();
|
||||
expect(scrollToSettingsSection('fonts')).toBe(true);
|
||||
expect(el.scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' }));
|
||||
});
|
||||
|
||||
it('still glides to a calendar day when motion is not reduced', () => {
|
||||
systemPrefers(false);
|
||||
const el = document.createElement('div');
|
||||
el.scrollTo = vi.fn() as typeof el.scrollTo;
|
||||
scrollToStart(el, 'vertical', 120);
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ top: 120, behavior: 'smooth' });
|
||||
document.documentElement.setAttribute('data-motion', 'reduce');
|
||||
scrollToStart(el, 'vertical', 40);
|
||||
expect(el.scrollTo).toHaveBeenLastCalledWith({ top: 40, behavior: 'auto' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Motion that scripts start themselves (smooth scrolling, animated
|
||||
* transitions set from JS) has to follow Reduce motion, which CSS alone can't
|
||||
* reach.
|
||||
*
|
||||
* Settings > Accessibility > Reduce motion writes data-motion on <html>
|
||||
* ("reduce" for On, "full" for Off, nothing for System; see
|
||||
* applyAccessibilityTo()), so the effective value is the attribute when it is
|
||||
* there and the operating system's preference otherwise.
|
||||
*/
|
||||
export function prefersReducedMotion(root: HTMLElement | null = typeof document !== 'undefined' ? document.documentElement : null): boolean {
|
||||
const choice = root?.getAttribute('data-motion');
|
||||
if (choice === 'reduce') return true;
|
||||
if (choice === 'full') return false;
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** `behavior` for scrollIntoView/scrollTo: smooth unless motion is reduced. */
|
||||
export function scrollBehavior(): ScrollBehavior {
|
||||
return prefersReducedMotion() ? 'auto' : 'smooth';
|
||||
}
|
||||
Reference in New Issue
Block a user