From d43a10ba7a1bae86f36bec735ebac572b5962a06 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 11 Dec 2024 11:30:25 -0500 Subject: [PATCH] feat: add `svelte/reactivity/window` module (#14660) * feat: add `svelte/reactivity/window` module * lint * fix * hide private types * online binding * tweak docs * tweak * add @since tags --------- Co-authored-by: Simon Holthausen --- .changeset/orange-ducks-obey.md | 5 + .../21-svelte-reactivity-window.md | 15 ++ packages/svelte/package.json | 4 + packages/svelte/scripts/generate-types.js | 1 + .../svelte/src/internal/client/dom/task.js | 2 +- packages/svelte/src/reactivity/media-query.js | 27 ++- .../svelte/src/reactivity/reactive-value.js | 24 +++ .../svelte/src/reactivity/window/index.js | 154 ++++++++++++++++++ packages/svelte/types/index.d.ts | 80 ++++++++- 9 files changed, 289 insertions(+), 23 deletions(-) create mode 100644 .changeset/orange-ducks-obey.md create mode 100644 documentation/docs/98-reference/21-svelte-reactivity-window.md create mode 100644 packages/svelte/src/reactivity/reactive-value.js create mode 100644 packages/svelte/src/reactivity/window/index.js diff --git a/.changeset/orange-ducks-obey.md b/.changeset/orange-ducks-obey.md new file mode 100644 index 0000000000..e17bf7ad42 --- /dev/null +++ b/.changeset/orange-ducks-obey.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `svelte/reactivity/window` module diff --git a/documentation/docs/98-reference/21-svelte-reactivity-window.md b/documentation/docs/98-reference/21-svelte-reactivity-window.md new file mode 100644 index 0000000000..cc544dc5ba --- /dev/null +++ b/documentation/docs/98-reference/21-svelte-reactivity-window.md @@ -0,0 +1,15 @@ +--- +title: svelte/reactivity/window +--- + +This module exports reactive versions of various `window` values, each of which has a reactive `current` property that you can reference in reactive contexts (templates, [deriveds]($derived) and [effects]($effect)) without using [``](svelte-window) bindings or manually creating your own event listeners. + +```svelte + + +

{innerWidth.current}x{innerHeight.current}

+``` + +> MODULE: svelte/reactivity/window diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 358d56ec5b..922c7fc9f9 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -73,6 +73,10 @@ "browser": "./src/reactivity/index-client.js", "default": "./src/reactivity/index-server.js" }, + "./reactivity/window": { + "types": "./types/index.d.ts", + "default": "./src/reactivity/window/index.js" + }, "./server": { "types": "./types/index.d.ts", "default": "./src/server/index.js" diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 8083851c35..16bbf52a2e 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -35,6 +35,7 @@ await createBundle({ [`${pkg.name}/legacy`]: `${dir}/src/legacy/legacy-client.js`, [`${pkg.name}/motion`]: `${dir}/src/motion/public.d.ts`, [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`, + [`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`, [`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`, [`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`, [`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`, diff --git a/packages/svelte/src/internal/client/dom/task.js b/packages/svelte/src/internal/client/dom/task.js index 95526b27a7..acb5a5b117 100644 --- a/packages/svelte/src/internal/client/dom/task.js +++ b/packages/svelte/src/internal/client/dom/task.js @@ -1,7 +1,7 @@ import { run_all } from '../../shared/utils.js'; // Fallback for when requestIdleCallback is not available -const request_idle_callback = +export const request_idle_callback = typeof requestIdleCallback === 'undefined' ? (/** @type {() => void} */ cb) => setTimeout(cb, 1) : requestIdleCallback; diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index a2be0adc91..7da9ada667 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -1,5 +1,5 @@ -import { createSubscriber } from './create-subscriber.js'; import { on } from '../events/index.js'; +import { ReactiveValue } from './reactive-value.js'; /** * Creates a media query and provides a `current` property that reflects whether or not it matches. @@ -16,26 +16,19 @@ import { on } from '../events/index.js'; * *

{large.current ? 'large screen' : 'small screen'}

* ``` + * @extends {ReactiveValue} * @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; - } - +export class MediaQuery extends ReactiveValue { /** * @param {string} query A media query string - * @param {boolean} [matches] Fallback value for the server + * @param {boolean} [fallback] 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})`); + constructor(query, fallback) { + const q = window.matchMedia(`(${query})`); + super( + () => q.matches, + (update) => on(q, 'change', update) + ); } } diff --git a/packages/svelte/src/reactivity/reactive-value.js b/packages/svelte/src/reactivity/reactive-value.js new file mode 100644 index 0000000000..c7c14ad7cd --- /dev/null +++ b/packages/svelte/src/reactivity/reactive-value.js @@ -0,0 +1,24 @@ +import { createSubscriber } from './create-subscriber.js'; + +/** + * @template T + */ +export class ReactiveValue { + #fn; + #subscribe; + + /** + * + * @param {() => T} fn + * @param {(update: () => void) => void} onsubscribe + */ + constructor(fn, onsubscribe) { + this.#fn = fn; + this.#subscribe = createSubscriber(onsubscribe); + } + + get current() { + this.#subscribe(); + return this.#fn(); + } +} diff --git a/packages/svelte/src/reactivity/window/index.js b/packages/svelte/src/reactivity/window/index.js new file mode 100644 index 0000000000..16e8b7b87b --- /dev/null +++ b/packages/svelte/src/reactivity/window/index.js @@ -0,0 +1,154 @@ +import { BROWSER } from 'esm-env'; +import { on } from '../../events/index.js'; +import { ReactiveValue } from '../reactive-value.js'; +import { get } from '../../internal/client/index.js'; +import { set, source } from '../../internal/client/reactivity/sources.js'; + +/** + * `scrollX.current` is a reactive view of `window.scrollX`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const scrollX = new ReactiveValue( + BROWSER ? () => window.scrollX : () => undefined, + (update) => on(window, 'scroll', update) +); + +/** + * `scrollY.current` is a reactive view of `window.scrollY`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const scrollY = new ReactiveValue( + BROWSER ? () => window.scrollY : () => undefined, + (update) => on(window, 'scroll', update) +); + +/** + * `innerWidth.current` is a reactive view of `window.innerWidth`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const innerWidth = new ReactiveValue( + BROWSER ? () => window.innerWidth : () => undefined, + (update) => on(window, 'resize', update) +); + +/** + * `innerHeight.current` is a reactive view of `window.innerHeight`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const innerHeight = new ReactiveValue( + BROWSER ? () => window.innerHeight : () => undefined, + (update) => on(window, 'resize', update) +); + +/** + * `outerWidth.current` is a reactive view of `window.outerWidth`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const outerWidth = new ReactiveValue( + BROWSER ? () => window.outerWidth : () => undefined, + (update) => on(window, 'resize', update) +); + +/** + * `outerHeight.current` is a reactive view of `window.outerHeight`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const outerHeight = new ReactiveValue( + BROWSER ? () => window.outerHeight : () => undefined, + (update) => on(window, 'resize', update) +); + +/** + * `screenLeft.current` is a reactive view of `window.screenLeft`. It is updated inside a `requestAnimationFrame` callback. On the server it is `undefined`. + * @since 5.11.0 + */ +export const screenLeft = new ReactiveValue( + BROWSER ? () => window.screenLeft : () => undefined, + (update) => { + let value = window.screenLeft; + + let frame = requestAnimationFrame(function check() { + frame = requestAnimationFrame(check); + + if (value !== (value = window.screenLeft)) { + update(); + } + }); + + return () => { + cancelAnimationFrame(frame); + }; + } +); + +/** + * `screenTop.current` is a reactive view of `window.screenTop`. It is updated inside a `requestAnimationFrame` callback. On the server it is `undefined`. + * @since 5.11.0 + */ +export const screenTop = new ReactiveValue( + BROWSER ? () => window.screenTop : () => undefined, + (update) => { + let value = window.screenTop; + + let frame = requestAnimationFrame(function check() { + frame = requestAnimationFrame(check); + + if (value !== (value = window.screenTop)) { + update(); + } + }); + + return () => { + cancelAnimationFrame(frame); + }; + } +); + +/** + * `online.current` is a reactive view of `navigator.onLine`. On the server it is `undefined`. + * @since 5.11.0 + */ +export const online = new ReactiveValue( + BROWSER ? () => navigator.onLine : () => undefined, + (update) => { + const unsub_online = on(window, 'online', update); + const unsub_offline = on(window, 'offline', update); + return () => { + unsub_online(); + unsub_offline(); + }; + } +); + +/** + * `devicePixelRatio.current` is a reactive view of `window.devicePixelRatio`. On the server it is `undefined`. + * Note that behaviour differs between browsers — on Chrome it will respond to the current zoom level, + * on Firefox and Safari it won't. + * @type {{ get current(): number }} + * @since 5.11.0 + */ +export const devicePixelRatio = /* @__PURE__ */ new (class DevicePixelRatio { + #dpr = source(BROWSER ? window.devicePixelRatio : undefined); + + #update() { + const off = on( + window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`), + 'change', + () => { + set(this.#dpr, window.devicePixelRatio); + + off(); + this.#update(); + } + ); + } + + constructor() { + this.#update(); + } + + get current() { + get(this.#dpr); + return window.devicePixelRatio; + } +})(); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 61a34dcb8e..206f9931f5 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1900,16 +1900,15 @@ declare module 'svelte/reactivity' { * *

{large.current ? 'large screen' : 'small screen'}

* ``` + * @extends {ReactiveValue} * @since 5.7.0 */ - export class MediaQuery { + export class MediaQuery extends ReactiveValue { /** * @param query A media query string - * @param matches Fallback value for the server + * @param fallback Fallback value for the server */ - constructor(query: string, matches?: boolean | undefined); - get current(): boolean; - #private; + constructor(query: string, fallback?: boolean | undefined); } /** * Returns a `subscribe` function that, if called in an effect (including expressions in the template), @@ -1953,6 +1952,77 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; + class ReactiveValue { + + constructor(fn: () => T, onsubscribe: (update: () => void) => void); + get current(): T; + #private; + } + + export {}; +} + +declare module 'svelte/reactivity/window' { + /** + * `scrollX.current` is a reactive view of `window.scrollX`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const scrollX: ReactiveValue; + /** + * `scrollY.current` is a reactive view of `window.scrollY`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const scrollY: ReactiveValue; + /** + * `innerWidth.current` is a reactive view of `window.innerWidth`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const innerWidth: ReactiveValue; + /** + * `innerHeight.current` is a reactive view of `window.innerHeight`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const innerHeight: ReactiveValue; + /** + * `outerWidth.current` is a reactive view of `window.outerWidth`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const outerWidth: ReactiveValue; + /** + * `outerHeight.current` is a reactive view of `window.outerHeight`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const outerHeight: ReactiveValue; + /** + * `screenLeft.current` is a reactive view of `window.screenLeft`. It is updated inside a `requestAnimationFrame` callback. On the server it is `undefined`. + * @since 5.11.0 + */ + export const screenLeft: ReactiveValue; + /** + * `screenTop.current` is a reactive view of `window.screenTop`. It is updated inside a `requestAnimationFrame` callback. On the server it is `undefined`. + * @since 5.11.0 + */ + export const screenTop: ReactiveValue; + /** + * `online.current` is a reactive view of `navigator.onLine`. On the server it is `undefined`. + * @since 5.11.0 + */ + export const online: ReactiveValue; + /** + * `devicePixelRatio.current` is a reactive view of `window.devicePixelRatio`. On the server it is `undefined`. + * Note that behaviour differs between browsers — on Chrome it will respond to the current zoom level, + * on Firefox and Safari it won't. + * @since 5.11.0 + */ + export const devicePixelRatio: { + get current(): number; + }; + class ReactiveValue { + + constructor(fn: () => T, onsubscribe: (update: () => void) => void); + get current(): T; + #private; + } export {}; }