Skip to content

Commit 0a9890b

Browse files
feat: provide MediaQuery / prefersReducedMotion (#14422)
* feat: provide `MediaQuery` / `prefersReducedMotion` closes #5346 * matches -> current, server fallback * createStartStopNotifier * test polyfill * more tests fixes * feedback * rename * tweak, types * hnnnggh * mark as pure * fix type check * notify -> subscribe * add links to inline docs * better API, more docs * add example to prefersReducedMotion * add example for MediaQuery * typo * fix example * tweak docs * changesets * note when APIs were added * add note * regenerate --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 73b3cf7 commit 0a9890b

File tree

12 files changed

+305
-37
lines changed

12 files changed

+305
-37
lines changed

.changeset/popular-worms-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add `createSubscriber` function for creating reactive values that depend on subscriptions

.changeset/quiet-tables-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add reactive `MediaQuery` class, and a `prefersReducedMotion` class instance

packages/svelte/src/motion/index.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,32 @@
1+
import { MediaQuery } from 'svelte/reactivity';
2+
13
export * from './spring.js';
24
export * from './tweened.js';
5+
6+
/**
7+
* A [media query](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery) that matches if the user [prefers reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).
8+
*
9+
* ```svelte
10+
* <script>
11+
* import { prefersReducedMotion } from 'svelte/motion';
12+
* import { fly } from 'svelte/transition';
13+
*
14+
* let visible = $state(false);
15+
* </script>
16+
*
17+
* <button onclick={() => visible = !visible}>
18+
* toggle
19+
* </button>
20+
*
21+
* {#if visible}
22+
* <p transition:fly={{ y: prefersReducedMotion.current ? 0 : 200 }}>
23+
* flies in, unless the user prefers reduced motion
24+
* </p>
25+
* {/if}
26+
* ```
27+
* @type {MediaQuery}
28+
* @since 5.7.0
29+
*/
30+
export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery(
31+
'(prefers-reduced-motion: reduce)'
32+
);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { get, tick, untrack } from '../internal/client/runtime.js';
2+
import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js';
3+
import { source } from '../internal/client/reactivity/sources.js';
4+
import { increment } from './utils.js';
5+
6+
/**
7+
* Returns a `subscribe` function that, if called in an effect (including expressions in the template),
8+
* calls its `start` callback with an `update` function. Whenever `update` is called, the effect re-runs.
9+
*
10+
* If `start` returns a function, it will be called when the effect is destroyed.
11+
*
12+
* If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects
13+
* are active, and the returned teardown function will only be called when all effects are destroyed.
14+
*
15+
* It's best understood with an example. Here's an implementation of [`MediaQuery`](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery):
16+
*
17+
* ```js
18+
* import { createSubscriber } from 'svelte/reactivity';
19+
* import { on } from 'svelte/events';
20+
*
21+
* export class MediaQuery {
22+
* #query;
23+
* #subscribe;
24+
*
25+
* constructor(query) {
26+
* this.#query = window.matchMedia(`(${query})`);
27+
*
28+
* this.#subscribe = createSubscriber((update) => {
29+
* // when the `change` event occurs, re-run any effects that read `this.current`
30+
* const off = on(this.#query, 'change', update);
31+
*
32+
* // stop listening when all the effects are destroyed
33+
* return () => off();
34+
* });
35+
* }
36+
*
37+
* get current() {
38+
* this.#subscribe();
39+
*
40+
* // Return the current state of the query, whether or not we're in an effect
41+
* return this.#query.matches;
42+
* }
43+
* }
44+
* ```
45+
* @param {(update: () => void) => (() => void) | void} start
46+
* @since 5.7.0
47+
*/
48+
export function createSubscriber(start) {
49+
let subscribers = 0;
50+
let version = source(0);
51+
/** @type {(() => void) | void} */
52+
let stop;
53+
54+
return () => {
55+
if (effect_tracking()) {
56+
get(version);
57+
58+
render_effect(() => {
59+
if (subscribers === 0) {
60+
stop = untrack(() => start(() => increment(version)));
61+
}
62+
63+
subscribers += 1;
64+
65+
return () => {
66+
tick().then(() => {
67+
// Only count down after timeout, else we would reach 0 before our own render effect reruns,
68+
// but reach 1 again when the tick callback of the prior teardown runs. That would mean we
69+
// re-subcribe unnecessarily and create a memory leak because the old subscription is never cleaned up.
70+
subscribers -= 1;
71+
72+
if (subscribers === 0) {
73+
stop?.();
74+
stop = undefined;
75+
}
76+
});
77+
};
78+
});
79+
}
80+
};
81+
}

packages/svelte/src/reactivity/index-client.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ export { SvelteSet } from './set.js';
33
export { SvelteMap } from './map.js';
44
export { SvelteURL } from './url.js';
55
export { SvelteURLSearchParams } from './url-search-params.js';
6+
export { MediaQuery } from './media-query.js';
7+
export { createSubscriber } from './create-subscriber.js';

packages/svelte/src/reactivity/index-server.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,21 @@ export const SvelteSet = globalThis.Set;
33
export const SvelteMap = globalThis.Map;
44
export const SvelteURL = globalThis.URL;
55
export const SvelteURLSearchParams = globalThis.URLSearchParams;
6+
7+
export class MediaQuery {
8+
current;
9+
/**
10+
* @param {string} query
11+
* @param {boolean} [matches]
12+
*/
13+
constructor(query, matches = false) {
14+
this.current = matches;
15+
}
16+
}
17+
18+
/**
19+
* @param {any} _
20+
*/
21+
export function createSubscriber(_) {
22+
return () => {};
23+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createSubscriber } from './create-subscriber.js';
2+
import { on } from '../events/index.js';
3+
4+
/**
5+
* Creates a media query and provides a `current` property that reflects whether or not it matches.
6+
*
7+
* Use it carefully — during server-side rendering, there is no way to know what the correct value should be, potentially causing content to change upon hydration.
8+
* If you can use the media query in CSS to achieve the same effect, do that.
9+
*
10+
* ```svelte
11+
* <script>
12+
* import { MediaQuery } from 'svelte/reactivity';
13+
*
14+
* const large = new MediaQuery('min-width: 800px');
15+
* </script>
16+
*
17+
* <h1>{large.current ? 'large screen' : 'small screen'}</h1>
18+
* ```
19+
* @since 5.7.0
20+
*/
21+
export class MediaQuery {
22+
#query;
23+
#subscribe = createSubscriber((update) => {
24+
return on(this.#query, 'change', update);
25+
});
26+
27+
get current() {
28+
this.#subscribe();
29+
30+
return this.#query.matches;
31+
}
32+
33+
/**
34+
* @param {string} query A media query string
35+
* @param {boolean} [matches] Fallback value for the server
36+
*/
37+
constructor(query, matches) {
38+
// For convenience (and because people likely forget them) we add the parentheses; double parentheses are not a problem
39+
this.#query = window.matchMedia(`(${query})`);
40+
}
41+
}

packages/svelte/src/store/index-client.js

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
/** @import { Readable, Writable } from './public.js' */
2-
import { noop } from '../internal/shared/utils.js';
32
import {
43
effect_root,
54
effect_tracking,
65
render_effect
76
} from '../internal/client/reactivity/effects.js';
8-
import { source } from '../internal/client/reactivity/sources.js';
9-
import { get as get_source, tick } from '../internal/client/runtime.js';
10-
import { increment } from '../reactivity/utils.js';
117
import { get, writable } from './shared/index.js';
8+
import { createSubscriber } from '../reactivity/create-subscriber.js';
129

1310
export { derived, get, readable, readonly, writable } from './shared/index.js';
1411

@@ -109,43 +106,23 @@ export function toStore(get, set) {
109106
*/
110107
export function fromStore(store) {
111108
let value = /** @type {V} */ (undefined);
112-
let version = source(0);
113-
let subscribers = 0;
114109

115-
let unsubscribe = noop;
110+
const subscribe = createSubscriber((update) => {
111+
let ran = false;
116112

117-
function current() {
118-
if (effect_tracking()) {
119-
get_source(version);
113+
const unsubscribe = store.subscribe((v) => {
114+
value = v;
115+
if (ran) update();
116+
});
120117

121-
render_effect(() => {
122-
if (subscribers === 0) {
123-
let ran = false;
124-
125-
unsubscribe = store.subscribe((v) => {
126-
value = v;
127-
if (ran) increment(version);
128-
});
129-
130-
ran = true;
131-
}
132-
133-
subscribers += 1;
134-
135-
return () => {
136-
tick().then(() => {
137-
// Only count down after timeout, else we would reach 0 before our own render effect reruns,
138-
// but reach 1 again when the tick callback of the prior teardown runs. That would mean we
139-
// re-subcribe unnecessarily and create a memory leak because the old subscription is never cleaned up.
140-
subscribers -= 1;
141-
142-
if (subscribers === 0) {
143-
unsubscribe();
144-
}
145-
});
146-
};
147-
});
118+
ran = true;
119+
120+
return unsubscribe;
121+
});
148122

123+
function current() {
124+
if (effect_tracking()) {
125+
subscribe();
149126
return value;
150127
}
151128

packages/svelte/tests/helpers.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,16 @@ export function write(file, contents) {
172172

173173
fs.writeFileSync(file, contents);
174174
}
175+
176+
// Guard because not all test contexts load this with JSDOM
177+
if (typeof window !== 'undefined') {
178+
// @ts-expect-error JS DOM doesn't support it
179+
Window.prototype.matchMedia = (media) => {
180+
return {
181+
matches: false,
182+
media,
183+
addEventListener: () => {},
184+
removeEventListener: () => {}
185+
};
186+
};
187+
}

packages/svelte/tests/motion/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// @vitest-environment jsdom
2+
import '../helpers.js'; // for the matchMedia polyfill
13
import { describe, it, assert } from 'vitest';
24
import { get } from 'svelte/store';
35
import { spring, tweened } from 'svelte/motion';

0 commit comments

Comments
 (0)