Skip to content

Commit

Permalink
feat: provide MediaQuery / prefersReducedMotion (#14422)
Browse files Browse the repository at this point in the history
* 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 <rich.harris@vercel.com>
  • Loading branch information
dummdidumm and Rich-Harris authored Dec 5, 2024
1 parent 73b3cf7 commit 0a9890b
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 37 deletions.
5 changes: 5 additions & 0 deletions .changeset/popular-worms-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `createSubscriber` function for creating reactive values that depend on subscriptions
5 changes: 5 additions & 0 deletions .changeset/quiet-tables-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add reactive `MediaQuery` class, and a `prefersReducedMotion` class instance
30 changes: 30 additions & 0 deletions packages/svelte/src/motion/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,32 @@
import { MediaQuery } from 'svelte/reactivity';

export * from './spring.js';
export * from './tweened.js';

/**
* 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).
*
* ```svelte
* <script>
* import { prefersReducedMotion } from 'svelte/motion';
* import { fly } from 'svelte/transition';
*
* let visible = $state(false);
* </script>
*
* <button onclick={() => visible = !visible}>
* toggle
* </button>
*
* {#if visible}
* <p transition:fly={{ y: prefersReducedMotion.current ? 0 : 200 }}>
* flies in, unless the user prefers reduced motion
* </p>
* {/if}
* ```
* @type {MediaQuery}
* @since 5.7.0
*/
export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery(
'(prefers-reduced-motion: reduce)'
);
81 changes: 81 additions & 0 deletions packages/svelte/src/reactivity/create-subscriber.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { get, tick, untrack } from '../internal/client/runtime.js';
import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js';
import { source } from '../internal/client/reactivity/sources.js';
import { increment } from './utils.js';

/**
* Returns a `subscribe` function that, if called in an effect (including expressions in the template),
* calls its `start` callback with an `update` function. Whenever `update` is called, the effect re-runs.
*
* If `start` returns a function, it will be called when the effect is destroyed.
*
* If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects
* are active, and the returned teardown function will only be called when all effects are destroyed.
*
* It's best understood with an example. Here's an implementation of [`MediaQuery`](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery):
*
* ```js
* import { createSubscriber } from 'svelte/reactivity';
* import { on } from 'svelte/events';
*
* export class MediaQuery {
* #query;
* #subscribe;
*
* constructor(query) {
* this.#query = window.matchMedia(`(${query})`);
*
* this.#subscribe = createSubscriber((update) => {
* // when the `change` event occurs, re-run any effects that read `this.current`
* const off = on(this.#query, 'change', update);
*
* // stop listening when all the effects are destroyed
* return () => off();
* });
* }
*
* get current() {
* this.#subscribe();
*
* // Return the current state of the query, whether or not we're in an effect
* return this.#query.matches;
* }
* }
* ```
* @param {(update: () => void) => (() => void) | void} start
* @since 5.7.0
*/
export function createSubscriber(start) {
let subscribers = 0;
let version = source(0);
/** @type {(() => void) | void} */
let stop;

return () => {
if (effect_tracking()) {
get(version);

render_effect(() => {
if (subscribers === 0) {
stop = untrack(() => start(() => increment(version)));
}

subscribers += 1;

return () => {
tick().then(() => {
// Only count down after timeout, else we would reach 0 before our own render effect reruns,
// but reach 1 again when the tick callback of the prior teardown runs. That would mean we
// re-subcribe unnecessarily and create a memory leak because the old subscription is never cleaned up.
subscribers -= 1;

if (subscribers === 0) {
stop?.();
stop = undefined;
}
});
};
});
}
};
}
2 changes: 2 additions & 0 deletions packages/svelte/src/reactivity/index-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export { SvelteSet } from './set.js';
export { SvelteMap } from './map.js';
export { SvelteURL } from './url.js';
export { SvelteURLSearchParams } from './url-search-params.js';
export { MediaQuery } from './media-query.js';
export { createSubscriber } from './create-subscriber.js';
18 changes: 18 additions & 0 deletions packages/svelte/src/reactivity/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,21 @@ export const SvelteSet = globalThis.Set;
export const SvelteMap = globalThis.Map;
export const SvelteURL = globalThis.URL;
export const SvelteURLSearchParams = globalThis.URLSearchParams;

export class MediaQuery {
current;
/**
* @param {string} query
* @param {boolean} [matches]
*/
constructor(query, matches = false) {
this.current = matches;
}
}

/**
* @param {any} _
*/
export function createSubscriber(_) {
return () => {};
}
41 changes: 41 additions & 0 deletions packages/svelte/src/reactivity/media-query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createSubscriber } from './create-subscriber.js';
import { on } from '../events/index.js';

/**
* Creates a media query and provides a `current` property that reflects whether or not it matches.
*
* 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.
* If you can use the media query in CSS to achieve the same effect, do that.
*
* ```svelte
* <script>
* import { MediaQuery } from 'svelte/reactivity';
*
* const large = new MediaQuery('min-width: 800px');
* </script>
*
* <h1>{large.current ? 'large screen' : 'small screen'}</h1>
* ```
* @since 5.7.0
*/
export class MediaQuery {
#query;
#subscribe = createSubscriber((update) => {
return on(this.#query, 'change', update);
});

get current() {
this.#subscribe();

return this.#query.matches;
}

/**
* @param {string} query A media query string
* @param {boolean} [matches] Fallback value for the server
*/
constructor(query, matches) {
// For convenience (and because people likely forget them) we add the parentheses; double parentheses are not a problem
this.#query = window.matchMedia(`(${query})`);
}
}
51 changes: 14 additions & 37 deletions packages/svelte/src/store/index-client.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
/** @import { Readable, Writable } from './public.js' */
import { noop } from '../internal/shared/utils.js';
import {
effect_root,
effect_tracking,
render_effect
} from '../internal/client/reactivity/effects.js';
import { source } from '../internal/client/reactivity/sources.js';
import { get as get_source, tick } from '../internal/client/runtime.js';
import { increment } from '../reactivity/utils.js';
import { get, writable } from './shared/index.js';
import { createSubscriber } from '../reactivity/create-subscriber.js';

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

Expand Down Expand Up @@ -109,43 +106,23 @@ export function toStore(get, set) {
*/
export function fromStore(store) {
let value = /** @type {V} */ (undefined);
let version = source(0);
let subscribers = 0;

let unsubscribe = noop;
const subscribe = createSubscriber((update) => {
let ran = false;

function current() {
if (effect_tracking()) {
get_source(version);
const unsubscribe = store.subscribe((v) => {
value = v;
if (ran) update();
});

render_effect(() => {
if (subscribers === 0) {
let ran = false;

unsubscribe = store.subscribe((v) => {
value = v;
if (ran) increment(version);
});

ran = true;
}

subscribers += 1;

return () => {
tick().then(() => {
// Only count down after timeout, else we would reach 0 before our own render effect reruns,
// but reach 1 again when the tick callback of the prior teardown runs. That would mean we
// re-subcribe unnecessarily and create a memory leak because the old subscription is never cleaned up.
subscribers -= 1;

if (subscribers === 0) {
unsubscribe();
}
});
};
});
ran = true;

return unsubscribe;
});

function current() {
if (effect_tracking()) {
subscribe();
return value;
}

Expand Down
13 changes: 13 additions & 0 deletions packages/svelte/tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,16 @@ export function write(file, contents) {

fs.writeFileSync(file, contents);
}

// Guard because not all test contexts load this with JSDOM
if (typeof window !== 'undefined') {
// @ts-expect-error JS DOM doesn't support it
Window.prototype.matchMedia = (media) => {
return {
matches: false,
media,
addEventListener: () => {},
removeEventListener: () => {}
};
};
}
2 changes: 2 additions & 0 deletions packages/svelte/tests/motion/test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// @vitest-environment jsdom
import '../helpers.js'; // for the matchMedia polyfill
import { describe, it, assert } from 'vitest';
import { get } from 'svelte/store';
import { spring, tweened } from 'svelte/motion';
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"svelte/motion": ["./src/motion/public.d.ts"],
"svelte/server": ["./src/server/index.d.ts"],
"svelte/store": ["./src/store/public.d.ts"],
"svelte/reactivity": ["./src/reactivity/index-client.js"],
"#compiler": ["./src/compiler/types/index.d.ts"],
"#client": ["./src/internal/client/types.d.ts"],
"#server": ["./src/internal/server/types.d.ts"],
Expand Down
Loading

0 comments on commit 0a9890b

Please sign in to comment.