Public Access
feat(settings): reduce animations switch
This commit is contained in:
1 parent
fa0857f55d
commit
2ec5833f29
9 files changed
+88
-12
No files matched your search
@@ -89,6 +89,8 @@
|
|||||||
},
|
},
|
||||||
"WIDE_LAYOUT": "Breites Layout",
|
"WIDE_LAYOUT": "Breites Layout",
|
||||||
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
|
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
|
||||||
|
"REDUCE_MOTION": "Animationen reduzieren",
|
||||||
|
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
|
||||||
"BACKGROUND": "Hintergrund",
|
"BACKGROUND": "Hintergrund",
|
||||||
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
||||||
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
||||||
|
|||||||
@@ -89,6 +89,8 @@
|
|||||||
},
|
},
|
||||||
"WIDE_LAYOUT": "Wide layout",
|
"WIDE_LAYOUT": "Wide layout",
|
||||||
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
|
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
|
||||||
|
"REDUCE_MOTION": "Reduce animations",
|
||||||
|
"REDUCE_MOTION_HINT": "Shop effects and confetti stand still, also on other profiles.",
|
||||||
"BACKGROUND": "Background",
|
"BACKGROUND": "Background",
|
||||||
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
|
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
|
||||||
"BACKGROUND_REMOVE": "Remove background",
|
"BACKGROUND_REMOVE": "Remove background",
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ export default function AppearanceCard() {
|
|||||||
<Switch id="wideLayout" checked={appearance.wideLayout}
|
<Switch id="wideLayout" checked={appearance.wideLayout}
|
||||||
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reduceMotion">{t('SETTINGS.REDUCE_MOTION')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.REDUCE_MOTION_HINT')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="reduceMotion" checked={appearance.reduceMotion}
|
||||||
|
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
import {LOGO_COLORS} from './Logo.jsx';
|
import {LOGO_COLORS} from './Logo.jsx';
|
||||||
|
import {reduceMotion} from '../utils/appearance.js';
|
||||||
|
|
||||||
const DURATION_MS = 2800;
|
const DURATION_MS = 2800;
|
||||||
const PIECES = {small: 70, big: 240};
|
const PIECES = {small: 70, big: 240};
|
||||||
@@ -8,8 +9,6 @@ const GRAVITY = 0.12;
|
|||||||
// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
|
// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
|
||||||
const FRAME_MS = 1000 / 60;
|
const FRAME_MS = 1000 / 60;
|
||||||
|
|
||||||
const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
||||||
|
|
||||||
// The logo colors of the chosen accent and the accent itself.
|
// The logo colors of the chosen accent and the accent itself.
|
||||||
function colors() {
|
function colors() {
|
||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
@@ -48,7 +47,7 @@ function pieces(size, width, height) {
|
|||||||
*/
|
*/
|
||||||
export default function Confetti({size, onDone}) {
|
export default function Confetti({size, onDone}) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const [reduce] = useState(reducedMotion);
|
const [reduce] = useState(reduceMotion);
|
||||||
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
|
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
|
||||||
const onDoneRef = useRef(onDone);
|
const onDoneRef = useRef(onDone);
|
||||||
onDoneRef.current = onDone;
|
onDoneRef.current = onDone;
|
||||||
|
|||||||
@@ -34,4 +34,13 @@ describe('Confetti', () => {
|
|||||||
|
|
||||||
expect(container.querySelector('canvas')).toBeNull();
|
expect(container.querySelector('canvas')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws nothing when the reduce animations setting is on', () => {
|
||||||
|
reducedMotion(false);
|
||||||
|
document.documentElement.setAttribute('data-reduce-motion', '');
|
||||||
|
const {container} = render(<Confetti size="small"/>);
|
||||||
|
document.documentElement.removeAttribute('data-reduce-motion');
|
||||||
|
|
||||||
|
expect(container.querySelector('canvas')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -54,7 +54,7 @@ describe('useSaveAppearance', () => {
|
|||||||
const [url, request] = fetch.mock.calls[0];
|
const [url, request] = fetch.mock.calls[0];
|
||||||
expect(url).toContain('/api/users/settings/appearance');
|
expect(url).toContain('/api/users/settings/appearance');
|
||||||
expect(request.method).toBe('PUT');
|
expect(request.method).toBe('PUT');
|
||||||
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
|
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('puts the old value back when Auth refuses', async () => {
|
it('puts the old value back when Auth refuses', async () => {
|
||||||
@@ -119,7 +119,7 @@ describe('useSaveAppearance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
|
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
|
||||||
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
|
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false, reduceMotion: false};
|
||||||
let answerMe;
|
let answerMe;
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
[PUT]: json(204),
|
[PUT]: json(204),
|
||||||
@@ -206,7 +206,7 @@ describe('useSaveAppearance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.preferences.appearance.corners).toBe('round');
|
expect(result.current.preferences.appearance.corners).toBe('round');
|
||||||
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true});
|
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends nothing for a change that waited for the load when a reset came first', async () => {
|
it('sends nothing for a change that waited for the load when a reset came first', async () => {
|
||||||
|
|||||||
@@ -187,11 +187,20 @@ describe('SettingsPage appearance', () => {
|
|||||||
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
|
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
|
||||||
|
|
||||||
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
|
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
|
||||||
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
|
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false});
|
||||||
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
|
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
|
||||||
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
|
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves the reduce animations switch', async () => {
|
||||||
|
renderPage(profile());
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
|
||||||
|
expect(appearancePuts()[0].reduceMotion).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('removes the background', async () => {
|
it('removes the background', async () => {
|
||||||
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
|
||||||
renderPage(profile());
|
renderPage(profile());
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const CORNERS = ['sharp', 'default', 'round'];
|
|||||||
// bannerAppid null: the dashboard banner shows the game played most in the last two weeks.
|
// bannerAppid null: the dashboard banner shows the game played most in the last two weeks.
|
||||||
export const DEFAULT_APPEARANCE = {
|
export const DEFAULT_APPEARANCE = {
|
||||||
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
|
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
|
||||||
|
reduceMotion: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
||||||
@@ -22,6 +23,7 @@ export const normalizeAppearance = (value) => ({
|
|||||||
backgroundAppid: appid(value?.backgroundAppid),
|
backgroundAppid: appid(value?.backgroundAppid),
|
||||||
bannerAppid: appid(value?.bannerAppid),
|
bannerAppid: appid(value?.bannerAppid),
|
||||||
showBanner: value?.showBanner !== false,
|
showBanner: value?.showBanner !== false,
|
||||||
|
reduceMotion: value?.reduceMotion === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,8 +43,14 @@ export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.str
|
|||||||
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
|
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
|
||||||
root.dataset.accent = accentOverride ?? appearance.accent;
|
root.dataset.accent = accentOverride ?? appearance.accent;
|
||||||
root.dataset.corners = appearance.corners;
|
root.dataset.corners = appearance.corners;
|
||||||
|
root.toggleAttribute('data-reduce-motion', appearance.reduceMotion === true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** True when the system asks for reduced motion or the "Reduce animations" setting is on. */
|
||||||
|
export const reduceMotion = () =>
|
||||||
|
(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||||
|
|| document.documentElement.hasAttribute('data-reduce-motion');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
|
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {
|
import {
|
||||||
applyAppearance,
|
applyAppearance,
|
||||||
cacheAppearance,
|
cacheAppearance,
|
||||||
DEFAULT_APPEARANCE,
|
DEFAULT_APPEARANCE,
|
||||||
normalizeAppearance,
|
normalizeAppearance,
|
||||||
readCachedAppearance,
|
readCachedAppearance,
|
||||||
|
reduceMotion,
|
||||||
} from './appearance.js';
|
} from './appearance.js';
|
||||||
|
|
||||||
describe('normalizeAppearance', () => {
|
describe('normalizeAppearance', () => {
|
||||||
it('keeps known values', () => {
|
it('keeps known values', () => {
|
||||||
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false};
|
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false, reduceMotion: true};
|
||||||
|
|
||||||
expect(normalizeAppearance(appearance)).toEqual(appearance);
|
expect(normalizeAppearance(appearance)).toEqual(appearance);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces unknown or missing values with the defaults', () => {
|
it('replaces unknown or missing values with the defaults', () => {
|
||||||
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1, bannerAppid: '620', showBanner: 'no'}))
|
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1, bannerAppid: '620', showBanner: 'no', reduceMotion: 'yes'}))
|
||||||
.toEqual(DEFAULT_APPEARANCE);
|
.toEqual(DEFAULT_APPEARANCE);
|
||||||
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
|
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
|
||||||
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
|
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
|
||||||
@@ -27,9 +28,9 @@ describe('appearance cache', () => {
|
|||||||
afterEach(() => localStorage.clear());
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
it('reads back what was cached', () => {
|
it('reads back what was cached', () => {
|
||||||
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true});
|
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true, reduceMotion: false});
|
||||||
|
|
||||||
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true});
|
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true, reduceMotion: false});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the defaults for a broken or empty cache', () => {
|
it('falls back to the defaults for a broken or empty cache', () => {
|
||||||
@@ -58,3 +59,41 @@ describe('applyAppearance', () => {
|
|||||||
expect(root.dataset.accent).toBe('gold');
|
expect(root.dataset.accent).toBe('gold');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reduce motion', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.removeAttribute('data-reduce-motion');
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaQuery = (matches) => vi.stubGlobal('matchMedia', () => ({matches}));
|
||||||
|
|
||||||
|
it('is set on the root by applyAppearance and removed again', () => {
|
||||||
|
const root = document.createElement('html');
|
||||||
|
|
||||||
|
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, null, root);
|
||||||
|
expect(root.hasAttribute('data-reduce-motion')).toBe(true);
|
||||||
|
|
||||||
|
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, null, root);
|
||||||
|
expect(root.hasAttribute('data-reduce-motion')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is on for the system media query', () => {
|
||||||
|
mediaQuery(true);
|
||||||
|
|
||||||
|
expect(reduceMotion()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is on for the setting', () => {
|
||||||
|
mediaQuery(false);
|
||||||
|
document.documentElement.setAttribute('data-reduce-motion', '');
|
||||||
|
|
||||||
|
expect(reduceMotion()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is off for neither', () => {
|
||||||
|
mediaQuery(false);
|
||||||
|
|
||||||
|
expect(reduceMotion()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user