Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: provide MediaQuery / prefersReducedMotion #14422

Merged
merged 25 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from 22 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
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
29 changes: 29 additions & 0 deletions packages/svelte/src/motion/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,31 @@
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}
*/
export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery(
'(prefers-reduced-motion: reduce)'
);
80 changes: 80 additions & 0 deletions packages/svelte/src/reactivity/create-subscriber.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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
*/
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 () => {};
}
39 changes: 39 additions & 0 deletions packages/svelte/src/reactivity/media-query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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.
*
* ```svelte
* <script>
* import { MediaQuery } from 'svelte/reactivity';
*
* const large = new MediaQuery('min-width: 800px');
* </script>
*
* <h1>{large.current ? 'large screen' : 'small screen'}</h1>
* ```
*/
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
Loading