diff --git a/docs/content/docs/core/async-state/index.md b/docs/content/docs/core/async-state/index.md
index fa4c089..57c1595 100644
--- a/docs/content/docs/core/async-state/index.md
+++ b/docs/content/docs/core/async-state/index.md
@@ -8,19 +8,17 @@ A reactive state that handles the loading and error states of a promise.
## Usage
-```svelte
-
+const recipe = asyncState(() => {
+ const result = fetch(`https://dummyjson.com/recipes/1`);
+
+ return result.json();
+}, null );
```
-## Examples
+### Examples
A basic example where you wait for the value to be resolved.
@@ -28,11 +26,11 @@ A basic example where you wait for the value to be resolved.
{#if recipe.isLoading}
@@ -52,8 +50,9 @@ Note that you have to set `immediate` to `false` if you are using a function tha
let id = $state(1);
const recipe = asyncState((id) => {
- return fetch(`https://dummyjson.com/recipes/${id}`)
- .then((res) => res.json());
+ const result = fetch(`https://dummyjson.com/recipes/${id}`);
+
+ return result.json();
}, null, { immediate: false });
$effect(() => {
diff --git a/docs/content/docs/core/auto-reset-state/index.md b/docs/content/docs/core/auto-reset-state/index.md
index 5304851..8b4040b 100644
--- a/docs/content/docs/core/auto-reset-state/index.md
+++ b/docs/content/docs/core/auto-reset-state/index.md
@@ -8,15 +8,13 @@ A state that automatically resets to the default value after a delay.
## Usage
-```svelte
-
+function changeMessage() {
+ // Changes to the default value after 3 seconds
+ message.current = 'This is the new message';
+}
```
diff --git a/docs/content/docs/core/create-eye-dropper/index.md b/docs/content/docs/core/create-eye-dropper/index.md
index e8752d6..2ba8d3a 100644
--- a/docs/content/docs/core/create-eye-dropper/index.md
+++ b/docs/content/docs/core/create-eye-dropper/index.md
@@ -10,10 +10,8 @@ Using this tool, users can sample colors from their screens, including outside o
## Usage
-```svelte
-
+const eyeDropper = createEyeDropper();
```
diff --git a/docs/content/docs/core/create-file-dialog/index.md b/docs/content/docs/core/create-file-dialog/index.md
index e657b73..259bbb0 100644
--- a/docs/content/docs/core/create-file-dialog/index.md
+++ b/docs/content/docs/core/create-file-dialog/index.md
@@ -8,32 +8,30 @@ Creates a file dialog to interact with programatically.
## Usage
-```svelte
-
+const dialog = createFileDialog();
```
### Examples
-```svelte
-
+```ts
+import { createFileDialog } from '@sv-use/core';
+
+const dialog = createFileDialog({
+ accept: 'image/*',
+ multiple: true,
+ onChange(files) {
+ console.log($state.snapshot(files));
+ },
+ onCancel() {
+ console.log('cancelled');
+ }
+});
+```
+```svelte
Open file dialog
diff --git a/docs/content/docs/core/create-share/index.md b/docs/content/docs/core/create-share/index.md
index ba4ae66..8611ef0 100644
--- a/docs/content/docs/core/create-share/index.md
+++ b/docs/content/docs/core/create-share/index.md
@@ -10,12 +10,12 @@ URLs, or files.
The available share targets depend on the device, but might include the
clipboard, contacts and email applications, websites, Bluetooth, etc.
-## Usage
-
> [!IMPORTANT]
> To prevent abuse, it must be triggered off a UI event like a button click and
> cannot be launched at arbitrary points by a script.
+## Usage
+
```js
import { createShare } from '@sv-use/core';
diff --git a/docs/content/docs/core/debounce/index.md b/docs/content/docs/core/debounce/index.md
index 6b398a9..f53830c 100644
--- a/docs/content/docs/core/debounce/index.md
+++ b/docs/content/docs/core/debounce/index.md
@@ -11,11 +11,9 @@ Debounces the update of the value after a delay.
> [!TIP]
> If you'd rather have them combined in one variable, check out [debouncedState](/docs/core/debounced-state).
-```svelte
-
+let search = $state('');
+const debouncedSearch = debounce(() => search);
```
diff --git a/docs/content/docs/core/debounced-state/index.md b/docs/content/docs/core/debounced-state/index.md
index de10558..99027db 100644
--- a/docs/content/docs/core/debounced-state/index.md
+++ b/docs/content/docs/core/debounced-state/index.md
@@ -11,10 +11,8 @@ A reactive state that updates its value after a delay.
> [!TIP]
> If you'd rather have them separate, check out [debounce](/docs/core/debounce).
-```svelte
-
+const search = debouncedState('', { delay: 1000 });
```
diff --git a/docs/content/docs/core/default-state/index.md b/docs/content/docs/core/default-state/index.md
index 44197a5..d9ae67d 100644
--- a/docs/content/docs/core/default-state/index.md
+++ b/docs/content/docs/core/default-state/index.md
@@ -13,14 +13,14 @@ A reactive state that falls back to `defaultValue` if set to `null` or `undefine
>
> This is to ensure that you can set a nullable value when changing the state without TS complaining.
-```svelte
-
+// Set the message to the fallback message
+message.current = null;
```
diff --git a/docs/content/docs/core/get-active-element/index.md b/docs/content/docs/core/get-active-element/index.md
index 65d1af5..54f5c9b 100644
--- a/docs/content/docs/core/get-active-element/index.md
+++ b/docs/content/docs/core/get-active-element/index.md
@@ -8,10 +8,8 @@ Gets the current active element in the DOM.
## Usage
-```svelte
-
+const activeElement = getActiveElement();
```
diff --git a/docs/content/docs/core/get-battery/Demo.svelte b/docs/content/docs/core/get-battery/Demo.svelte
deleted file mode 100644
index 57ccb26..0000000
--- a/docs/content/docs/core/get-battery/Demo.svelte
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
{JSON.stringify(battery, null, 2)}
diff --git a/docs/content/docs/core/get-battery/index.md b/docs/content/docs/core/get-battery/index.md
deleted file mode 100644
index 5c9dfb5..0000000
--- a/docs/content/docs/core/get-battery/index.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-category: sensors
----
-
-# getBattery
-
-Provides information about the system's battery charge level and lets you be notified by events that are sent when the battery level or charging status change.
-
-## Usage
-
-```svelte
-
-```
diff --git a/docs/content/docs/core/get-clipboard-text/index.md b/docs/content/docs/core/get-clipboard-text/index.md
index ad60bcb..011d344 100644
--- a/docs/content/docs/core/get-clipboard-text/index.md
+++ b/docs/content/docs/core/get-clipboard-text/index.md
@@ -10,15 +10,13 @@ Provides write (and optionally read) access to the text clipboard.
Set `options.legacyCopy: true` to keep the ability to copy if the [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API) is not available. It will handle copy with [document.execCommand](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand) as the fallback.
-```svelte
-
+const clipboard = getClipboardText({
+ allowRead: true,
+ legacyCopy: true
+});
```
## Examples
diff --git a/docs/content/docs/core/get-device-motion/index.md b/docs/content/docs/core/get-device-motion/index.md
index 4d7f5ba..bcbfed8 100644
--- a/docs/content/docs/core/get-device-motion/index.md
+++ b/docs/content/docs/core/get-device-motion/index.md
@@ -9,10 +9,8 @@ rotation rate.
## Usage
-```svelte
-
+const deviceMotion = getDeviceMotion();
```
diff --git a/docs/content/docs/core/get-device-orientation/index.md b/docs/content/docs/core/get-device-orientation/index.md
index 16d6042..0e477df 100644
--- a/docs/content/docs/core/get-device-orientation/index.md
+++ b/docs/content/docs/core/get-device-orientation/index.md
@@ -9,10 +9,8 @@ device running the web page.
## Usage
-```svelte
-
+const deviceOrientation = getDeviceOrientation();
```
diff --git a/docs/content/docs/core/get-device-pixel-ratio/index.md b/docs/content/docs/core/get-device-pixel-ratio/index.md
index 0b486ff..ac3eb41 100644
--- a/docs/content/docs/core/get-device-pixel-ratio/index.md
+++ b/docs/content/docs/core/get-device-pixel-ratio/index.md
@@ -9,10 +9,8 @@ pixels for the current display device.
## Usage
-```svelte
-
+const devicePixelRatio = getDevicePixelRatio();
```
diff --git a/docs/content/docs/core/get-document-visibility/index.md b/docs/content/docs/core/get-document-visibility/index.md
index 291ba0b..5be949a 100644
--- a/docs/content/docs/core/get-document-visibility/index.md
+++ b/docs/content/docs/core/get-document-visibility/index.md
@@ -11,10 +11,8 @@ minimized window, or is otherwise not visible to the user.
## Usage
-```svelte
-
+const documentVisibility = getDocumentVisibility();
```
diff --git a/docs/content/docs/core/get-fps/index.md b/docs/content/docs/core/get-fps/index.md
index ddfa52d..0a6bf3e 100644
--- a/docs/content/docs/core/get-fps/index.md
+++ b/docs/content/docs/core/get-fps/index.md
@@ -8,10 +8,8 @@ Get the current frames per second of the device.
## Usage
-```svelte
-
+const fps = getFps();
```
diff --git a/docs/content/docs/core/get-geolocation/index.md b/docs/content/docs/core/get-geolocation/index.md
index bea2337..65507fc 100644
--- a/docs/content/docs/core/get-geolocation/index.md
+++ b/docs/content/docs/core/get-geolocation/index.md
@@ -7,15 +7,14 @@ category: sensors
It allows the user to provide their location to web applications if they so
desire.
-For privacy reasons, the user is asked for permission to report location
-information.
+> [!IMPORTANT]
+> For privacy reasons, the user is asked for permission to report location
+> information.
## Usage
-```svelte
-
+const geolocation = getGeolocation();
```
diff --git a/docs/content/docs/core/get-last-changed/index.md b/docs/content/docs/core/get-last-changed/index.md
index f941944..651077c 100644
--- a/docs/content/docs/core/get-last-changed/index.md
+++ b/docs/content/docs/core/get-last-changed/index.md
@@ -8,11 +8,9 @@ Get the last time the reactive value changed. It is returned as a number in mill
## Usage
-```svelte
-
+let value = $state(0);
+const lastChanged = getLastChanged(() => value);
```
diff --git a/docs/content/docs/core/get-mouse-pressed/index.md b/docs/content/docs/core/get-mouse-pressed/index.md
index 9e73c7f..9d9521a 100644
--- a/docs/content/docs/core/get-mouse-pressed/index.md
+++ b/docs/content/docs/core/get-mouse-pressed/index.md
@@ -8,10 +8,8 @@ Reactive values for mouse/touch/drag pressing state.
## Usage
-```svelte
-
+const mousePressed = getMousePressed();
```
diff --git a/docs/content/docs/core/get-mouse/index.md b/docs/content/docs/core/get-mouse/index.md
index 7f34bc1..0be390e 100644
--- a/docs/content/docs/core/get-mouse/index.md
+++ b/docs/content/docs/core/get-mouse/index.md
@@ -8,10 +8,8 @@ Retrieves information about the mouse.
## Usage
-```svelte
-
+const mouse = getMouse();
```
diff --git a/docs/content/docs/core/get-network/index.md b/docs/content/docs/core/get-network/index.md
index ae9ade2..90e16aa 100644
--- a/docs/content/docs/core/get-network/index.md
+++ b/docs/content/docs/core/get-network/index.md
@@ -9,10 +9,8 @@ the network.
## Usage
-```svelte
-
+const network = getNetwork();
```
diff --git a/docs/content/docs/core/get-permission/Demo.svelte b/docs/content/docs/core/get-permission/Demo.svelte
index e67b435..e198c9e 100644
--- a/docs/content/docs/core/get-permission/Demo.svelte
+++ b/docs/content/docs/core/get-permission/Demo.svelte
@@ -1,10 +1,48 @@
-
Camera status
-
{JSON.stringify(permission, null, 2)}
+
{JSON.stringify(code, null, 2)}
diff --git a/docs/content/docs/core/get-permission/index.md b/docs/content/docs/core/get-permission/index.md
index 9a50601..5fa015e 100644
--- a/docs/content/docs/core/get-permission/index.md
+++ b/docs/content/docs/core/get-permission/index.md
@@ -4,14 +4,25 @@ category: browser
# getPermission
-Retrieves the status of a given permission.
+Reactive [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API).
+It provides a consistent programmatic way to query the status of API
+permissions attributed to the current context, such as a web page or worker.
+
+For example, it can be used to determine if permission to access a particular
+feature or API has been granted, denied, or requires specific user permission.
## Usage
-```svelte
-
+// Use the permission
+if (microphoneAccess.current === "granted") {
+ // We have access to the microphone here
+}
```
diff --git a/docs/content/docs/core/get-previous/index.md b/docs/content/docs/core/get-previous/index.md
index 29e1b35..ea6e66d 100644
--- a/docs/content/docs/core/get-previous/index.md
+++ b/docs/content/docs/core/get-previous/index.md
@@ -6,8 +6,6 @@ category: reactivity
A reactive state of a given state's previous value.
-It is set to `undefined` until the first change if `initial` is not set.
-
## Usage
> [!TIP]
@@ -15,11 +13,16 @@ It is set to `undefined` until the first change if `initial` is not set.
>
> It supplies the previous value in the callback.
-```svelte
-
+// With initial value
+let previousCounter = getPrevious(() => counter, -1);
```
diff --git a/docs/content/docs/core/get-scrollbar-width/index.md b/docs/content/docs/core/get-scrollbar-width/index.md
index e292b08..49d79fd 100644
--- a/docs/content/docs/core/get-scrollbar-width/index.md
+++ b/docs/content/docs/core/get-scrollbar-width/index.md
@@ -6,8 +6,9 @@ category: sensors
Gets the scrollbar width of an element.
-This works in browsers that do not use absolute positioning for scrollbars,
-such as Chrome on desktop.
+> [!NOTE]
+> This works in browsers that do not use absolute positioning for scrollbars,
+> such as Chrome on desktop.
## Usage
diff --git a/docs/content/docs/core/get-text-direction/index.md b/docs/content/docs/core/get-text-direction/index.md
index b341303..bebaa46 100644
--- a/docs/content/docs/core/get-text-direction/index.md
+++ b/docs/content/docs/core/get-text-direction/index.md
@@ -7,3 +7,18 @@ category: browser
Indicates the text writing directionality of the content of an element.
You can set a value to change the `dir` property on the element.
+
+## Usage
+
+```svelte
+
+
+
+ i'm a paragraph in english
+
+```
diff --git a/docs/content/docs/core/handle-event-listener/Demo.svelte b/docs/content/docs/core/handle-event-listener/Demo.svelte
index 67e5d0c..0b58d91 100644
--- a/docs/content/docs/core/handle-event-listener/Demo.svelte
+++ b/docs/content/docs/core/handle-event-listener/Demo.svelte
@@ -1,21 +1,13 @@
diff --git a/docs/content/docs/core/handle-event-listener/index.md b/docs/content/docs/core/handle-event-listener/index.md
index 723b78b..e29debb 100644
--- a/docs/content/docs/core/handle-event-listener/index.md
+++ b/docs/content/docs/core/handle-event-listener/index.md
@@ -8,12 +8,10 @@ Convenience wrapper for event listeners.
## Usage
-```svelte
-
+handleEventListener('click', () => {
+ console.log('clicked')
+});
```
diff --git a/docs/content/docs/core/handle-wake-lock/index.md b/docs/content/docs/core/handle-wake-lock/index.md
index 0fc7776..160c172 100644
--- a/docs/content/docs/core/handle-wake-lock/index.md
+++ b/docs/content/docs/core/handle-wake-lock/index.md
@@ -14,18 +14,16 @@ You may read more about the [Screen Wake Lock API](https://developer.mozilla.org
> Since it uses `$effect` internally, you must either call `handleWakeLock` in
> the component initialization lifecycle or call it inside `$effect.root`.
-```svelte
-
+// When you don't need it anymore
+await wakeLock.release();
```
diff --git a/docs/content/docs/core/has-left-page/index.md b/docs/content/docs/core/has-left-page/index.md
index 2a37441..b56712d 100644
--- a/docs/content/docs/core/has-left-page/index.md
+++ b/docs/content/docs/core/has-left-page/index.md
@@ -8,10 +8,8 @@ Reactive value that tracks whether the mouse has left the page or not.
## Usage
-```svelte
-
+const hasLeft = hasLeftPage();
```
diff --git a/docs/content/docs/core/history-state/index.md b/docs/content/docs/core/history-state/index.md
index 61dc6d9..3001a67 100644
--- a/docs/content/docs/core/history-state/index.md
+++ b/docs/content/docs/core/history-state/index.md
@@ -12,12 +12,10 @@ capabilities as well as access to the histories.
> [!TIP]
> If you prefer to have them separate, check out [trackHistory](/docs/core/track-history).
-```svelte
-
+const counter = historyState(0);
```
## Examples
diff --git a/docs/content/docs/core/is-window-focused/index.md b/docs/content/docs/core/is-window-focused/index.md
index be03e01..9652224 100644
--- a/docs/content/docs/core/is-window-focused/index.md
+++ b/docs/content/docs/core/is-window-focused/index.md
@@ -8,10 +8,8 @@ Tracks whether the window is focused or not.
## Usage
-```svelte
-
+const isFocused = isWindowFocused();
```
diff --git a/docs/content/docs/core/observe-performance/index.md b/docs/content/docs/core/observe-performance/index.md
index f2fcee6..e01288d 100644
--- a/docs/content/docs/core/observe-performance/index.md
+++ b/docs/content/docs/core/observe-performance/index.md
@@ -11,12 +11,10 @@ the entry types that have been registered.
## Usage
-```svelte
-
+observePerformance((list) => {
+ console.log(list.getEntries());
+}, { entryTypes: ['paint'] });
```
diff --git a/docs/content/docs/core/track-history/index.md b/docs/content/docs/core/track-history/index.md
index 1ad2a9f..1fc59c5 100644
--- a/docs/content/docs/core/track-history/index.md
+++ b/docs/content/docs/core/track-history/index.md
@@ -12,14 +12,12 @@ capabilities as well as access to the histories.
> [!TIP]
> If you prefer to have them combined, check out [historyState](/docs/core/history-state).
-```svelte
-
+let counter = $state(0);
+const counterHistory = trackHistory(
+ () => counter,
+ (v) => (counter = v)
+);
```
diff --git a/docs/content/docs/core/watch/index.md b/docs/content/docs/core/watch/index.md
index 54cd810..703c4ba 100644
--- a/docs/content/docs/core/watch/index.md
+++ b/docs/content/docs/core/watch/index.md
@@ -12,37 +12,49 @@ Provides the previous value(s) as well as the current one(s) as parameters in th
You can watch changes on a single value :
-```svelte
-
+watch(() => counter, (curr, prev) => {
+ console.log(`Went from ${prev} to ${curr}`);
+});
```
Or on multiple values by supplying an array :
-```svelte
-
+watch(
+ [() => counter, () => search],
+ ([currCounter, currSearch], [prevCounter, prevSearch]) => {
+ // ...
+ }
+);
+```
+
+You can return a function from `watch`, which will run immediately before it
+re-runs, and before it is destroyed.
+
+```ts
+import { watch } from '@sv-use/core';
+
+let element = $state
();
+
+watch(() => element, (el) => {
+ const observer = new MutationObserver(callback);
+ observer!.observe(el, { attributes: true });
+
+ return () => observer.disconnect();
+});
```
-### onMount
+### Options : Immediate
By default, the callback runs when the component is first mounted in the DOM,
as well as when a dependency changes.
@@ -50,14 +62,36 @@ as well as when a dependency changes.
You might not want that and only run when a dependency changes. You can set
this in the options.
+```ts
+import { watch } from '@sv-use/core';
+
+let counter = $state(0);
+
+watch(() => counter, (curr, prev) => {
+ console.log(`Went from ${prev} to ${curr}`);
+}, { immediate: false });
+```
+
+### Options : Deep
+
+By default, the callback only re-runs when the object it reads changes, not
+when a property inside it changes.
+
+You might also want to watch for deep changes, which you can set via the `deep`
+option.
+
```svelte
+
+ counter.value++}>
+ {counter.value}
+
```
diff --git a/docs/content/docs/core/whenever/index.md b/docs/content/docs/core/whenever/index.md
index 509b65f..8d5e29f 100644
--- a/docs/content/docs/core/whenever/index.md
+++ b/docs/content/docs/core/whenever/index.md
@@ -11,14 +11,30 @@ dependencies are truthy.
## Usage
-```svelte
-
+whenever(() => isActive, () => {
+ console.log('Active now !');
+});
+```
+
+You can return a function from `whenever`, which will run immediately before it
+re-runs, and before it is destroyed.
+
+```ts
+import { whenever } from '@sv-use/core';
+
+let isActive = $state(false);
+let counter = $state(0);
+
+whenever(() => isActive, () => {
+ const interval = setInterval(() => {
+ counter += 1;
+ }, 1000)
+
+ return () => clearInterval(interval);
+});
```
diff --git a/docs/content/docs/guide/introduction.md b/docs/content/docs/guide/introduction.md
index e1baaf0..2be7311 100644
--- a/docs/content/docs/guide/introduction.md
+++ b/docs/content/docs/guide/introduction.md
@@ -30,44 +30,3 @@ An example using a state that is persisted via local storage :
counter : {counter.current}
```
-
-## Best Practices
-
-### Cleanup
-
-Some utilities produce side-effects, such as invoking an event listener. By
-default, they are automatically cleaned up in an `onDestroy` hook.
-
-However, this requires the function to be called in the component
-initialization lifecycle.
-
-To opt out of this, every utility that produces a side-effect returns a cleanup
-function that can be used to clean it manually.
-
-Here is an example using [handleEventListener](/docs/core/handle-event-listener) :
-
-```svelte
-
-```
-
-And how to cleanup manually :
-
-```svelte
-
-```
diff --git a/docs/src/lib/utils/markdown.server.ts b/docs/src/lib/utils/markdown.server.ts
index 8258461..a96dd5a 100644
--- a/docs/src/lib/utils/markdown.server.ts
+++ b/docs/src/lib/utils/markdown.server.ts
@@ -29,7 +29,17 @@ function extractDataFromMarkdown(
async function convertMarkdownContentToHTML(
content: string
): Promise, 'attributes'>> {
- const { value, data } = await unified()
+ const { value, data } = await createProcessor().process(content);
+
+ return {
+ ...extractDataFromHTML(value.toString()),
+ // Remove h1 from headings
+ headings: (data.headings as MarkdownHeading[]).slice(1)
+ };
+}
+
+function createProcessor() {
+ return unified()
.use(remarkParse)
.use(remarkHeadingId, {
defaults: true,
@@ -47,30 +57,16 @@ async function convertMarkdownContentToHTML(
dark: 'one-dark-pro'
}
})
- .use(rehypeStringify)
- .process(content);
-
- let html = value.toString();
-
- const h1Regex = /(.*?)<\/h1>/;
- const paragraphRegex = / ([\s\S]*?)<\/p>/;
-
- const title = html.match(h1Regex)?.at(2);
- html = html.replace(h1Regex, '');
-
- const paragraphs = [];
- while (true) {
- const h2Index = html.indexOf('
(.*?)<\/h1>/;
+ const title = html.match(h1Regex)?.at(2);
+
+ return { title, html: html.replace(h1Regex, '') };
+}
+
+function extractLedeFromHTML(html: string) {
+ const [lede, ...rest] = html.split(' {
+ return '(
let { attributes, body } = extractDataFromMarkdown(content);
if (typeDefinitionsPath) {
- const typeDefinitions = await fs.readFile(typeDefinitionsPath, 'utf8');
+ body = await injectTypeDefinitions(body, typeDefinitionsPath);
+ }
- body += `
+ const data = await convertMarkdownContentToHTML(body);
+
+ return { attributes, ...data };
+ } catch (error) {
+ throw new Error(`Error converting ${filePath} to HTML: ${(error as Error).message}`);
+ }
+}
+
+async function injectTypeDefinitions(body: string, typeDefinitionsPath: string) {
+ const typeDefinitions = await fs.readFile(typeDefinitionsPath, 'utf8');
+
+ return (
+ body +
+ `
## Type Definitions
@@ -123,13 +151,6 @@ export async function convertMarkdownFileToHTML(
\`\`\`typescript
${typeDefinitions}\`\`\`
- `;
- }
-
- const data = await convertMarkdownContentToHTML(body);
-
- return { attributes, ...data };
- } catch (error) {
- throw new Error(`Error converting ${filePath} to HTML: ${(error as Error).message}`);
- }
+`
+ );
}
diff --git a/docs/src/routes/+layout.svelte b/docs/src/routes/+layout.svelte
index cdbe477..dce6261 100644
--- a/docs/src/routes/+layout.svelte
+++ b/docs/src/routes/+layout.svelte
@@ -1,6 +1,5 @@
+
+
= T | T[];
export type CleanupFunction = () => void;
-export interface AutoCleanup {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-}
-
export type MaybeElement = HTMLElement | SVGElement | undefined | null;
diff --git a/packages/core/src/__internal__/utils.svelte.ts b/packages/core/src/__internal__/utils.svelte.ts
index 1e83440..ef664e5 100644
--- a/packages/core/src/__internal__/utils.svelte.ts
+++ b/packages/core/src/__internal__/utils.svelte.ts
@@ -2,8 +2,7 @@ import type { Getter } from './types.js';
export const noop = () => {};
-export function toArray(v: T): T extends Array ? T : T[] {
- // @ts-expect-error Bypass type error
+export function toArray(v: T | T[]): T[] {
return Array.isArray(v) ? v : [v];
}
@@ -28,3 +27,47 @@ export function asyncEffectRoot(cb: () => Promise) {
return () => promise.finally(cleanup);
}
+
+export interface SingletonPromiseReturn {
+ (): Promise;
+ /**
+ * Reset current staled promise.
+ * await it to have proper shutdown.
+ */
+ reset: () => Promise;
+}
+
+/**
+ * Create singleton promise function
+ *
+ * @example
+ * ```
+ * const promise = createSingletonPromise(async () => { ... })
+ *
+ * await promise()
+ * await promise() // all of them will be bind to a single promise instance
+ * await promise() // and be resolved together
+ * ```
+ */
+export function createSingletonPromise(fn: () => Promise): SingletonPromiseReturn {
+ let _promise: Promise | undefined;
+
+ function wrapper() {
+ if (!_promise) {
+ _promise = fn();
+ }
+
+ return _promise;
+ }
+
+ wrapper.reset = async function () {
+ const _prev = _promise;
+ _promise = undefined;
+
+ if (_prev) {
+ await _prev;
+ }
+ };
+
+ return wrapper;
+}
diff --git a/packages/core/src/create-drop-zone/index.svelte.ts b/packages/core/src/create-drop-zone/index.svelte.ts
index 7070f78..6edea68 100644
--- a/packages/core/src/create-drop-zone/index.svelte.ts
+++ b/packages/core/src/create-drop-zone/index.svelte.ts
@@ -1,7 +1,6 @@
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
import { noop, normalizeValue, toArray } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction, MaybeGetter } from '../__internal__/types.js';
-import { untrack } from 'svelte';
+import type { MaybeGetter } from '../__internal__/types.js';
type CreateDropZoneOptions = {
/**
@@ -32,7 +31,6 @@ type CreateDropZoneOptions = {
type CreateDropZoneReturn = {
readonly isOver: boolean;
files: File[] | null;
- cleanup: CleanupFunction;
};
/**
@@ -55,37 +53,16 @@ export function createDropZone(
onOver = noop
} = options;
- let cleanups: CleanupFunction[] = [];
let counter = 0;
let isValid = true;
- const _target = $derived(normalizeValue(target));
let isOver = $state(false);
let files = $state(null);
- $effect(() => {
- if (_target) {
- untrack(() => {
- cleanups.push(
- handleEventListener(_target, 'dragenter', (event) =>
- handleDragEvent(event, 'enter')
- ),
- handleEventListener(_target, 'dragover', (event) =>
- handleDragEvent(event, 'over')
- ),
- handleEventListener(_target, 'dragleave', (event) =>
- handleDragEvent(event, 'leave')
- ),
- handleEventListener(_target, 'drop', (event) => handleDragEvent(event, 'drop'))
- );
- });
- }
-
- return () => {
- cleanup();
- cleanups = [];
- };
- });
+ handleEventListener(target, 'dragenter', (event) => handleDragEvent(event, 'enter'));
+ handleEventListener(target, 'dragover', (event) => handleDragEvent(event, 'over'));
+ handleEventListener(target, 'dragleave', (event) => handleDragEvent(event, 'leave'));
+ handleEventListener(target, 'drop', (event) => handleDragEvent(event, 'drop'));
function getFiles(event: DragEvent) {
const list = Array.from(event.dataTransfer?.files ?? []);
@@ -179,10 +156,6 @@ export function createDropZone(
}
}
- function cleanup() {
- cleanups.map((fn) => fn());
- }
-
return {
get files() {
return files;
@@ -192,7 +165,6 @@ export function createDropZone(
},
get isOver() {
return isOver;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/create-eye-dropper/index.svelte.test.ts b/packages/core/src/create-eye-dropper/index.svelte.test.ts
new file mode 100644
index 0000000..ecda154
--- /dev/null
+++ b/packages/core/src/create-eye-dropper/index.svelte.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createEyeDropper, type EyeDropperOpenOptions, type SRGBHex } from './index.svelte.js';
+
+class MockEyeDropper {
+ open(options?: EyeDropperOpenOptions) {
+ return new Promise<{ sRGBHex: SRGBHex }>((resolve) => resolve({ sRGBHex: '#ff3e00' }));
+ }
+}
+
+describe('createEyeDropper', () => {
+ it('Returns isSupported = false if it is not supported', () => {
+ const eyeDropper = createEyeDropper();
+
+ expect(eyeDropper.isSupported).toBe(false);
+ });
+
+ it('Returns undefined when opening if it is not supported', async () => {
+ const eyeDropper = createEyeDropper();
+ const openSpy = vi.spyOn(eyeDropper, 'open');
+
+ expect(eyeDropper.isSupported).toBe(false);
+
+ await eyeDropper.open();
+
+ // open returns undefined if it is not supported
+ expect(openSpy).toHaveResolvedWith(undefined);
+ });
+});
+
+describe('createEyeDropper', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ window.EyeDropper = MockEyeDropper;
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ window.EyeDropper = undefined;
+ });
+
+ it('Is initially undefined if it is supported', () => {
+ const eyeDropper = createEyeDropper();
+
+ expect(eyeDropper.isSupported).toBeTruthy();
+ expect(eyeDropper.current).toBeUndefined();
+ });
+
+ it('Is equal to the initial value if given', () => {
+ const initialValue = '#ff3e00';
+ const eyeDropper = createEyeDropper({ initialValue });
+
+ expect(eyeDropper.isSupported).toBeTruthy();
+ expect(eyeDropper.current).toBe(initialValue);
+ });
+
+ it('Opens the eye dropper and returns the selected color', async () => {
+ const eyeDropper = createEyeDropper();
+
+ expect(eyeDropper.isSupported).toBeTruthy();
+
+ const result = await eyeDropper.open();
+
+ expect(result).toEqual({ sRGBHex: '#ff3e00' });
+ });
+
+ it('Opens the eye dropper and updates the current property', async () => {
+ const eyeDropper = createEyeDropper();
+ const openSpy = vi.spyOn(eyeDropper, 'open');
+
+ expect(eyeDropper.isSupported).toBeTruthy();
+ expect(eyeDropper.current).toBeUndefined();
+
+ await eyeDropper.open();
+
+ expect(eyeDropper.current).toBe('#ff3e00');
+ });
+
+ it('Updates the current property when changed directly', () => {
+ const eyeDropper = createEyeDropper();
+
+ expect(eyeDropper.isSupported).toBeTruthy();
+ expect(eyeDropper.current).toBeUndefined();
+
+ eyeDropper.current = '#ff3e00';
+ expect(eyeDropper.current).toBe('#ff3e00');
+ });
+
+ it('Updates the current property when changed directly multiple times', () => {
+ const eyeDropper = createEyeDropper();
+
+ expect(eyeDropper.isSupported).toBeTruthy();
+ expect(eyeDropper.current).toBeUndefined();
+
+ eyeDropper.current = '#ff3e00';
+ expect(eyeDropper.current).toBe('#ff3e00');
+
+ eyeDropper.current = '#ff00aa';
+ expect(eyeDropper.current).toBe('#ff00aa');
+ });
+});
diff --git a/packages/core/src/create-eye-dropper/index.svelte.ts b/packages/core/src/create-eye-dropper/index.svelte.ts
index 8ee15b9..5245a18 100644
--- a/packages/core/src/create-eye-dropper/index.svelte.ts
+++ b/packages/core/src/create-eye-dropper/index.svelte.ts
@@ -1,9 +1,8 @@
-import { isSupported } from '../__internal__/is.svelte.js';
import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type SRGBHex = `#${string}`;
+export type SRGBHex = `#${string}`;
-interface EyeDropperOpenOptions {
+export interface EyeDropperOpenOptions {
/** @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */
signal?: AbortSignal;
}
@@ -31,38 +30,43 @@ type CreateEyeDropperReturn = {
/** Whether the {@link https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper_API | `Eye Dropper API`} is supported or not. */
readonly isSupported: boolean;
/** The current value selected in the eye dropper tool. */
- readonly current: SRGBHex | undefined;
+ current: SRGBHex | undefined;
/** A callback to open the eye dropper tool. */
open: (options?: EyeDropperOpenOptions) => Promise<{ sRGBHex: SRGBHex } | undefined>;
};
/**
* Provides a mechanism for creating an eye dropper tool.
+ * @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/create-eye-dropper
*/
export function createEyeDropper(options: CreateEyeDropperOptions = {}): CreateEyeDropperReturn {
const { initialValue = undefined, window = defaultWindow } = options;
- const _isSupported = isSupported(() => !!window && 'EyeDropper' in window);
- let _current = $state(initialValue);
+ const isSupported = $derived(!!window && 'EyeDropper' in window);
+ let current = $state(initialValue);
async function open(openOptions?: EyeDropperOpenOptions) {
- if (!_isSupported.current || !window) return;
+ if (!isSupported) return;
const eyeDropper: EyeDropper = new (window as WindowWithEyeDropper).EyeDropper();
+
const result = await eyeDropper.open(openOptions);
- _current = result.sRGBHex;
+ current = result.sRGBHex;
return result;
}
return {
get isSupported() {
- return _isSupported.current;
+ return isSupported;
},
get current() {
- return _current;
+ return current;
+ },
+ set current(v) {
+ current = v;
},
open
};
diff --git a/packages/core/src/create-file-dialog/index.svelte.ts b/packages/core/src/create-file-dialog/index.svelte.ts
index 38218fc..4587168 100644
--- a/packages/core/src/create-file-dialog/index.svelte.ts
+++ b/packages/core/src/create-file-dialog/index.svelte.ts
@@ -1,15 +1,7 @@
-import { onDestroy } from 'svelte';
import { BROWSER } from 'esm-env';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
type CreateFileDialogOptions = {
- /**
- * Whether to automatically clean up the event listeners or not.
- * @note If set to `true`, you must call `createFileDialog` in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
/** @default '*' */
accept?: string;
/** @default false */
@@ -36,8 +28,6 @@ type CreateFileDialogReturn = {
open: () => void;
/** Resets the file dialog. */
reset: () => void;
- /** Cleans up the input node and the event listeners. */
- cleanup: CleanupFunction;
};
/**
@@ -46,39 +36,24 @@ type CreateFileDialogReturn = {
* @see https://svelte-librarian.github.io/sv-use/docs/core/create-file-dialog
*/
export function createFileDialog(options: CreateFileDialogOptions = {}): CreateFileDialogReturn {
- const {
- autoCleanup = true,
- accept = '*',
- multiple = false,
- onChange = () => {},
- onCancel = () => {}
- } = options;
+ const { accept = '*', multiple = false, onChange = () => {}, onCancel = () => {} } = options;
let _files = $state([]);
let _input = $state();
- const cleanups: CleanupFunction[] = [];
-
if (BROWSER) {
_input = document.createElement('input');
_input.type = 'file';
_input.accept = accept;
_input.multiple = multiple;
- cleanups.push(
- handleEventListener(_input, 'change', (event) => {
- _files = Array.from((event.currentTarget as EventTarget & HTMLInputElement).files ?? []);
- onChange(_files);
- }),
- handleEventListener(_input, 'cancel', () => {
- onCancel();
- })
- );
- }
+ handleEventListener(_input, 'change', (event) => {
+ _files = Array.from((event.currentTarget as EventTarget & HTMLInputElement).files ?? []);
+ onChange(_files);
+ });
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
+ handleEventListener(_input, 'cancel', () => {
+ onCancel();
});
}
@@ -96,17 +71,11 @@ export function createFileDialog(options: CreateFileDialogOptions = {}): CreateF
}
}
- function cleanup() {
- cleanups.forEach((fn) => fn());
- _input?.remove();
- }
-
return {
get files() {
return _files;
},
open,
- reset,
- cleanup
+ reset
};
}
diff --git a/packages/core/src/create-object-url/index.svelte.test.ts b/packages/core/src/create-object-url/index.svelte.test.ts
new file mode 100644
index 0000000..921462f
--- /dev/null
+++ b/packages/core/src/create-object-url/index.svelte.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createObjectUrl } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+describe('createObjectUrl', () => {
+ beforeEach(() => {
+ window.URL.createObjectURL = vi.fn(() => crypto.randomUUID());
+ window.URL.revokeObjectURL = vi.fn();
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ window.URL.createObjectURL.mockReset();
+ // @ts-ignore
+ window.URL.revokeObjectURL.mockReset();
+ });
+
+ it('Returns null if the object is null or undefined', () => {
+ const cleanup = $effect.root(() => {
+ const objectUrl = createObjectUrl(null);
+ expect(objectUrl.current).toBeNull();
+
+ const objectUrl2 = createObjectUrl(undefined);
+ expect(objectUrl2.current).toBeNull();
+ });
+
+ cleanup();
+ });
+
+ it('Accepts a blob and outputs a URL', () => {
+ const cleanup = $effect.root(() => {
+ const blob = new Blob(['Hello, world!'], { type: 'text/plain' });
+
+ const objectUrl = createObjectUrl(blob);
+
+ flushSync();
+
+ expect(objectUrl.current).not.toBeNull();
+ });
+
+ cleanup();
+ });
+
+ it('Updates after the object changes', () => {
+ const cleanup = $effect.root(() => {
+ let blob = $state(new Blob(['Hello, world!'], { type: 'text/plain' }));
+
+ const objectUrl = createObjectUrl(() => blob);
+
+ flushSync();
+
+ expect(objectUrl.current).not.toBeNull();
+
+ const lastObjectUrl = objectUrl.current;
+
+ flushSync(() => {
+ blob = new Blob(['Bye, world!'], { type: 'text/plain' });
+ });
+
+ expect(objectUrl.current).not.toBe(lastObjectUrl);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/create-object-url/index.svelte.ts b/packages/core/src/create-object-url/index.svelte.ts
index 4b4575d..f9b96c8 100644
--- a/packages/core/src/create-object-url/index.svelte.ts
+++ b/packages/core/src/create-object-url/index.svelte.ts
@@ -1,9 +1,8 @@
import { normalizeValue } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction, MaybeGetter } from '../__internal__/types.js';
+import type { MaybeGetter } from '../__internal__/types.js';
type CreateObjectUrlReturn = {
readonly current: string | null;
- cleanup: CleanupFunction;
};
/**
@@ -14,29 +13,23 @@ type CreateObjectUrlReturn = {
export function createObjectUrl(
object: MaybeGetter
): CreateObjectUrlReturn {
+ const _object = $derived(normalizeValue(object)!);
let current = $state(null);
- const _object = $derived(normalizeValue(object));
$effect(() => {
if (_object) {
current = URL.createObjectURL(_object);
}
- return cleanup;
+ return () => {
+ current && URL.revokeObjectURL(current);
+ current = null;
+ };
});
- function cleanup() {
- if (current) {
- URL.revokeObjectURL(current);
- }
-
- current = null;
- }
-
return {
get current() {
return current;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/create-speech-recognition/index.svelte.ts b/packages/core/src/create-speech-recognition/index.svelte.ts
index 49e4d2a..17fe4fe 100644
--- a/packages/core/src/create-speech-recognition/index.svelte.ts
+++ b/packages/core/src/create-speech-recognition/index.svelte.ts
@@ -1,5 +1,4 @@
-import { whenever } from '../whenever/index.svelte.js';
-import { isSupported } from '../__internal__/is.svelte.js';
+import { watch } from '../watch/index.svelte.js';
import { noop, normalizeValue } from '../__internal__/utils.svelte.js';
import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
import type { SpeechRecognition, SpeechRecognitionErrorEvent } from './types.js';
@@ -84,9 +83,9 @@ export function createSpeechRecognition(
const SpeechRecognition =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
window && ((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition);
- const _isSupported = isSupported(() => SpeechRecognition);
+ const isSupported = $derived(!!SpeechRecognition);
- if (_isSupported.current) {
+ if (isSupported) {
recognition = new SpeechRecognition() as SpeechRecognition;
recognition.continuous = continuous;
@@ -118,31 +117,43 @@ export function createSpeechRecognition(
recognition.onend = () => {
isListening = false;
+ recognition!.lang = normalizeValue(lang);
};
- whenever(
- () => !!_lang && !!recognition && !isListening,
- () => {
- recognition!.lang = _lang;
+ watch(
+ () => _lang,
+ (curr) => {
+ if (recognition && !isListening) {
+ recognition.lang = curr;
+ }
+ }
+ );
+
+ watch(
+ () => isListening,
+ (curr, old) => {
+ if (curr === old) return;
+
+ if (curr) {
+ recognition!.start();
+ } else {
+ recognition!.stop();
+ }
}
);
}
function start() {
- if (!isListening) {
- recognition?.start();
- }
+ isListening = true;
}
function stop() {
- if (isListening) {
- recognition?.stop();
- }
+ isListening = false;
}
return {
get isSupported() {
- return _isSupported.current;
+ return isSupported;
},
get isListening() {
return isListening;
diff --git a/packages/core/src/create-vibration/index.svelte.test.ts b/packages/core/src/create-vibration/index.svelte.test.ts
new file mode 100644
index 0000000..99cd866
--- /dev/null
+++ b/packages/core/src/create-vibration/index.svelte.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createVibration } from './index.svelte.js';
+
+describe('createVibration', () => {
+ it('Does not work if it is not supported', () => {
+ const vibration = createVibration();
+
+ expect(vibration.isSupported).toBeFalsy();
+ });
+});
+
+describe('createVibration', () => {
+ beforeEach(() => {
+ navigator.vibrate = vi.fn();
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ navigator.vibrate.mockReset();
+ });
+
+ it('Works if it is supported', () => {
+ const vibration = createVibration();
+
+ expect(vibration.isSupported).toBeTruthy();
+ expect(navigator.vibrate).not.toHaveBeenCalled();
+
+ vibration.vibrate();
+
+ expect(navigator.vibrate).toHaveBeenCalledOnce();
+ });
+
+ it('Stops the vibration correctly', () => {
+ const vibration = createVibration();
+
+ expect(vibration.isSupported).toBeTruthy();
+ expect(navigator.vibrate).not.toHaveBeenCalled();
+
+ vibration.vibrate();
+
+ expect(navigator.vibrate).toHaveBeenCalledOnce();
+
+ vibration.stop();
+
+ expect(navigator.vibrate).toHaveBeenCalledTimes(2);
+ });
+
+ it('Accepts a pattern', () => {
+ const vibration = createVibration({ pattern: 300 });
+
+ expect(vibration.isSupported).toBeTruthy();
+ expect(navigator.vibrate).not.toHaveBeenCalled();
+
+ vibration.vibrate();
+
+ expect(navigator.vibrate).toHaveBeenCalledExactlyOnceWith(300);
+ });
+
+ it('Accepts a reactive pattern', () => {
+ let pattern = $state(300);
+ const vibration = createVibration({ pattern: () => pattern });
+
+ expect(vibration.isSupported).toBeTruthy();
+ expect(navigator.vibrate).not.toHaveBeenCalled();
+
+ vibration.vibrate();
+
+ expect(navigator.vibrate).toHaveBeenCalledExactlyOnceWith(300);
+
+ pattern = [300, 100, 200];
+
+ vibration.vibrate();
+
+ expect(navigator.vibrate).toHaveBeenCalledWith([300, 100, 200]);
+ expect(navigator.vibrate).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/core/src/get-active-element/index.svelte.test.ts b/packages/core/src/get-active-element/index.svelte.test.ts
new file mode 100644
index 0000000..e0ebd68
--- /dev/null
+++ b/packages/core/src/get-active-element/index.svelte.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { getActiveElement } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+describe('getActiveElement', () => {
+ let input: HTMLInputElement;
+
+ let shadowHost: HTMLElement;
+ let shadowInput: HTMLInputElement;
+ let shadowRoot: ShadowRoot;
+
+ beforeEach(() => {
+ input = document.createElement('input');
+
+ shadowHost = document.createElement('div');
+ shadowRoot = shadowHost.attachShadow({ mode: 'open' });
+
+ shadowInput = input.cloneNode() as HTMLInputElement;
+ shadowRoot.appendChild(shadowInput);
+
+ document.body.appendChild(input);
+ document.body.appendChild(shadowHost);
+ });
+
+ afterEach(() => {
+ shadowHost.remove();
+ input.remove();
+ });
+
+ it('Initializes correctly', () => {
+ const cleanup = $effect.root(() => {
+ const activeElement = getActiveElement();
+
+ expect(activeElement.current).toBe(document.body);
+ });
+
+ cleanup();
+ });
+
+ it('Initializes correctly with an already focused element', () => {
+ const cleanup = $effect.root(() => {
+ input.focus();
+
+ const activeElement = getActiveElement();
+
+ expect(activeElement.current).toBe(input);
+ });
+
+ cleanup();
+ });
+
+ it('Observes focus/blur events', () => {
+ const cleanup = $effect.root(() => {
+ const activeElement = getActiveElement();
+
+ flushSync();
+
+ input.focus();
+
+ expect(activeElement.current).toBe(input);
+
+ input.blur();
+
+ expect(activeElement.current).toBe(document.body);
+ });
+
+ cleanup();
+ });
+
+ it('Accepts a custom document', () => {
+ const cleanup = $effect.root(() => {
+ const activeElement = getActiveElement({ document: shadowRoot as unknown as Document });
+
+ flushSync();
+
+ shadowInput.focus();
+
+ expect(activeElement.current).toBe(shadowInput);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-active-element/index.svelte.ts b/packages/core/src/get-active-element/index.svelte.ts
index 047b1f1..c2fb31d 100644
--- a/packages/core/src/get-active-element/index.svelte.ts
+++ b/packages/core/src/get-active-element/index.svelte.ts
@@ -1,54 +1,48 @@
-import { onDestroy } from 'svelte';
-import { BROWSER } from 'esm-env';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
+import {
+ defaultDocument,
+ defaultWindow,
+ type ConfigurableDocument,
+ type ConfigurableWindow
+} from '../__internal__/configurable.js';
-type GetActiveElementOptions = {
- /**
- * Whether to automatically cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
+interface GetActiveElementOptions extends ConfigurableWindow, ConfigurableDocument {
/**
* Whether to search for the active element inside shadow DOM or not.
* @default true
*/
searchInShadow?: boolean;
-};
+}
type GetActiveElementReturn = {
- /** The current active element or `null`. */
readonly current: HTMLElement | null;
- /**
- * The function to cleanup the event listener.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: () => void;
};
/**
* Returns the element within the DOM that currently has focus.
+ * @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-active-element
*/
export function getActiveElement(options: GetActiveElementOptions = {}): GetActiveElementReturn {
- const { autoCleanup = true, searchInShadow = true } = options;
+ const { searchInShadow = true, window = defaultWindow, document = window?.document } = options;
- const cleanups: CleanupFunction[] = [];
- let _current = $state(null);
+ let current = $state(null);
- if (BROWSER) {
- cleanups.push(
- handleEventListener('blur', onBlur, { autoCleanup, capture: true }),
- handleEventListener('focus', onFocus, { autoCleanup, capture: true })
- );
+ if (window) {
+ handleEventListener(window, 'blur', onBlur, { capture: true, passive: true });
+ handleEventListener(window, 'focus', onFocus, { capture: true, passive: true });
}
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
+ onFocus();
+
+ function onFocus() {
+ current = getDeepActiveElement() as HTMLElement | null;
+ }
+
+ function onBlur(event: FocusEvent) {
+ if (event.relatedTarget !== null) return;
+
+ onFocus();
}
function getDeepActiveElement() {
@@ -63,24 +57,9 @@ export function getActiveElement(options: GetActiveElementOptions = {}): GetActi
return element;
}
- function onFocus() {
- _current = getDeepActiveElement() as HTMLElement | null;
- }
-
- function onBlur(event: FocusEvent) {
- if (event.relatedTarget !== null) return;
-
- onFocus();
- }
-
- function cleanup() {
- cleanups.forEach((fn) => fn());
- }
-
return {
get current() {
- return _current;
- },
- cleanup
+ return current;
+ }
};
}
diff --git a/packages/core/src/get-battery/index.svelte.ts b/packages/core/src/get-battery/index.svelte.ts
deleted file mode 100644
index 29de7fe..0000000
--- a/packages/core/src/get-battery/index.svelte.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { onDestroy } from 'svelte';
-import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
-
-// Custom type because only 1 out of 3 major browsers support it.
-export interface BatteryManager extends EventTarget {
- readonly charging: number;
- readonly chargingTime: number;
- readonly dischargingTime: number;
- readonly level: number;
-}
-
-type NavigatorWithBattery = Navigator & {
- getBattery: () => Promise;
-};
-
-type GetBatteryOptions = {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-};
-
-type GetBatteryReturn = {
- /** Whether the {@link https://developer.mozilla.org/en-US/docs/Web/API/Battery_Status_API | Battery Status API} is supported by the browser or not. */
- readonly isSupported: boolean;
- /** Whether the battery is currently being charged or not. */
- readonly charging: number;
- /** The remaining time in seconds until the battery is fully charged, or 0 if the battery is already fully charged. */
- readonly chargingTime: number;
- /** The remaining time in seconds until the battery is completely discharged and the system suspends. */
- readonly dischargingTime: number;
- /** The system's battery charge level scaled to a value between 0.0 and 1.0. */
- readonly level: number;
- /**
- * Cleans up the event listeners.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
-};
-
-/**
- * Retrieves information about the battery.
- * @see https://svelte-librarian.github.io/sv-use/docs/core/get-battery
- */
-export function getBattery(options: GetBatteryOptions = {}): GetBatteryReturn {
- const { autoCleanup = true } = options;
-
- const events = ['chargingchange', 'chargingtimechange', 'dischargingtimechange', 'levelchange'];
-
- let battery: BatteryManager;
- let cleanup: CleanupFunction = noop;
-
- const _isSupported = $derived.by(() => navigator && 'getBattery' in navigator);
- let _charging = $state(0);
- let _chargingTime = $state(0);
- let _dischargingTime = $state(0);
- let _level = $state(1);
-
- if (_isSupported) {
- (navigator as NavigatorWithBattery).getBattery().then((_battery) => {
- battery = _battery;
- updateBatteryInfo.call(battery);
- cleanup = handleEventListener(battery, events, updateBatteryInfo, {
- autoCleanup,
- passive: true
- });
- });
- }
-
- if (autoCleanup) {
- onDestroy(() => cleanup());
- }
-
- function updateBatteryInfo(this: BatteryManager) {
- _charging = this.charging || 0;
- _chargingTime = this.chargingTime || 0;
- _dischargingTime = this.dischargingTime || 0;
- _level = this.level || 1;
- }
-
- return {
- get isSupported() {
- return _isSupported;
- },
- get charging() {
- return _charging;
- },
- get chargingTime() {
- return _chargingTime;
- },
- get dischargingTime() {
- return _dischargingTime;
- },
- get level() {
- return _level;
- },
- cleanup
- };
-}
diff --git a/packages/core/src/get-clipboard-text/index.svelte.ts b/packages/core/src/get-clipboard-text/index.svelte.ts
index 67f7e1b..ddb2a2c 100644
--- a/packages/core/src/get-clipboard-text/index.svelte.ts
+++ b/packages/core/src/get-clipboard-text/index.svelte.ts
@@ -1,17 +1,9 @@
-import { onDestroy } from 'svelte';
import { BROWSER } from 'esm-env';
import { getPermission } from '../get-permission/index.svelte.js';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
-import { noop } from '../__internal__/utils.svelte.js';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type GetClipboardOptions = {
- /**
- * Whether to automatically clean up the event listeners or not.
- * @note If set to `true`, you must call `getClipboardText` in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
+interface GetClipboardOptions extends ConfigurableWindow {
/**
* Whether to allow reading from the clipboard.
* @default false
@@ -27,7 +19,7 @@ type GetClipboardOptions = {
* @default false
*/
legacyCopy?: boolean;
-};
+}
type GetClipboardReturn = {
readonly isSupported: boolean;
@@ -36,8 +28,6 @@ type GetClipboardReturn = {
readonly text: string;
/** Copies text to the clipboard. */
copyText: (value: string) => void;
- /** Cleans up the event listeners. */
- cleanup: CleanupFunction;
};
/**
@@ -49,34 +39,28 @@ export function getClipboardText(
options: GetClipboardOptions = {}
): GetClipboardReturn {
const {
- autoCleanup = true,
allowRead = false,
copyDuration = 2000,
- legacyCopy = false
+ legacyCopy = false,
+ window = defaultWindow
} = options;
const _isClipboardAPISupported = $derived.by(() => navigator && 'clipboard' in navigator);
const _isSupported = $derived.by(() => _isClipboardAPISupported || legacyCopy);
- const _readPermission = getPermission('clipboard-read', { exposeControls: true });
+ const _readPermission = getPermission('clipboard-read');
const _writePermission = getPermission('clipboard-write');
+
let _isCopied = $state(false);
let _text = $state('');
- let cleanup: CleanupFunction = noop;
if (BROWSER && _isSupported && allowRead) {
- cleanup = handleEventListener(['copy', 'cut'], readText);
- }
-
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
+ handleEventListener(window!, ['copy', 'cut'], readText);
}
function copyText(value: string) {
if (!_isSupported) return;
- if (_isClipboardAPISupported && _writePermission) {
+ if (_isClipboardAPISupported && _writePermission.current === 'granted') {
navigator.clipboard.writeText(value).then(() => {
_isCopied = true;
@@ -123,7 +107,6 @@ export function getClipboardText(
get text() {
return _text;
},
- copyText,
- cleanup
+ copyText
};
}
diff --git a/packages/core/src/get-device-motion/index.svelte.test.ts b/packages/core/src/get-device-motion/index.svelte.test.ts
new file mode 100644
index 0000000..fc2d2c6
--- /dev/null
+++ b/packages/core/src/get-device-motion/index.svelte.test.ts
@@ -0,0 +1,135 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { getDeviceMotion } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+class MockDeviceMotionEvent extends Event {
+ public acceleration: DeviceMotionEventAcceleration = { x: null, y: null, z: null };
+ public accelerationIncludingGravity: DeviceMotionEventAcceleration = {
+ x: null,
+ y: null,
+ z: null
+ };
+ public rotationRate: DeviceMotionEventRotationRate = { alpha: null, beta: null, gamma: null };
+ public interval: number = 0;
+
+ constructor(type: string, eventInitDict?: DeviceMotionEventInit) {
+ super(type);
+
+ if (eventInitDict?.acceleration) {
+ this.acceleration = {
+ x: null,
+ y: null,
+ z: null,
+ ...eventInitDict.acceleration
+ };
+ }
+
+ if (eventInitDict?.accelerationIncludingGravity) {
+ this.accelerationIncludingGravity = {
+ x: null,
+ y: null,
+ z: null,
+ ...eventInitDict.accelerationIncludingGravity
+ };
+ }
+
+ if (eventInitDict?.rotationRate) {
+ this.rotationRate = { alpha: null, beta: null, gamma: null, ...eventInitDict.rotationRate };
+ }
+
+ if (eventInitDict?.interval) {
+ this.interval = eventInitDict.interval;
+ }
+ }
+}
+
+describe('getDeviceMotion', () => {
+ it('Contains null values if it is not supported', () => {
+ const cleanup = $effect.root(() => {
+ const deviceMotion = getDeviceMotion();
+
+ expect(deviceMotion.isSupported).toBe(false);
+ expect(deviceMotion.acceleration).toStrictEqual({ x: null, y: null, z: null });
+ expect(deviceMotion.accelerationIncludingGravity).toStrictEqual({
+ x: null,
+ y: null,
+ z: null
+ });
+ expect(deviceMotion.rotationRate).toStrictEqual({ alpha: null, beta: null, gamma: null });
+ expect(deviceMotion.interval).toBe(0);
+ });
+
+ cleanup();
+ });
+});
+
+describe('getDeviceMotion', () => {
+ beforeEach(() => {
+ window.DeviceMotionEvent = MockDeviceMotionEvent;
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ window.DeviceMotionEvent = undefined;
+ });
+
+ it('Contains null values if it is supported', () => {
+ const cleanup = $effect.root(() => {
+ const deviceMotion = getDeviceMotion();
+
+ expect(deviceMotion.isSupported).toBe(true);
+ expect(deviceMotion.acceleration).toStrictEqual({ x: null, y: null, z: null });
+ expect(deviceMotion.accelerationIncludingGravity).toStrictEqual({
+ x: null,
+ y: null,
+ z: null
+ });
+ expect(deviceMotion.rotationRate).toStrictEqual({ alpha: null, beta: null, gamma: null });
+ expect(deviceMotion.interval).toBe(0);
+ });
+
+ cleanup();
+ });
+
+ it("Updates correctly after 'devicemotion' event", () => {
+ const cleanup = $effect.root(() => {
+ const acceleration: DeviceMotionEventAccelerationInit = {
+ x: 1,
+ y: 5,
+ z: null
+ };
+ const accelerationIncludingGravity: DeviceMotionEventAccelerationInit = {
+ x: null,
+ y: 10,
+ z: 2
+ };
+ const rotationRate: DeviceMotionEventRotationRateInit = {
+ alpha: 10,
+ beta: null,
+ gamma: 7
+ };
+ const interval = 3;
+
+ const deviceMotion = getDeviceMotion();
+
+ flushSync();
+
+ window.dispatchEvent(
+ new DeviceMotionEvent('devicemotion', {
+ acceleration,
+ accelerationIncludingGravity,
+ rotationRate,
+ interval
+ })
+ );
+
+ expect(deviceMotion.isSupported).toBe(true);
+ expect(deviceMotion.acceleration).toStrictEqual(acceleration);
+ expect(deviceMotion.accelerationIncludingGravity).toStrictEqual(accelerationIncludingGravity);
+ expect(deviceMotion.rotationRate).toStrictEqual(rotationRate);
+ expect(deviceMotion.interval).toBe(interval);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-device-motion/index.svelte.ts b/packages/core/src/get-device-motion/index.svelte.ts
index 81e42f0..19ded31 100644
--- a/packages/core/src/get-device-motion/index.svelte.ts
+++ b/packages/core/src/get-device-motion/index.svelte.ts
@@ -1,18 +1,17 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { isSupported } from '../__internal__/is.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type GetDeviceMotionOptions = {
+interface GetDeviceMotionOptions extends ConfigurableWindow {
/**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
+ * Whether to request for permission immediately if it's not granted, otherwise `label` and `deviceIds` could be empty.
+ * @default false
*/
- autoCleanup?: boolean;
-};
+ requestPermissions?: boolean;
+}
+
+interface GetDeviceMotionEventiOS extends GetDeviceMotionOptions {
+ requestPermission: () => Promise<'granted' | 'denied'>;
+}
type GetDeviceMotionReturn = {
/** Whether the device supports the {@link https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent | `DeviceMotionEvent`} feature or not. */
@@ -35,82 +34,125 @@ type GetDeviceMotionReturn = {
readonly rotationRate: DeviceMotionEventRotationRate;
/** The interval, in milliseconds, at which data is obtained from the underlying hardware. */
readonly interval: number;
- /**
- * Cleans up the event listener.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
+ readonly isPermissionGranted: boolean;
+ readonly requiresPermissions: boolean;
+ ensurePermissions: () => Promise;
};
/**
* Provides information about the device's motion, including acceleration and rotation rate.
+ * @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-device-motion
*/
export function getDeviceMotion(options: GetDeviceMotionOptions = {}): GetDeviceMotionReturn {
- const { autoCleanup = true } = options;
+ const { requestPermissions = false, window = defaultWindow } = options;
- let cleanup: CleanupFunction = noop;
+ const isSupported = $derived.by(() => !!window && typeof DeviceMotionEvent !== 'undefined');
+ const requiresPermissions = $derived.by(() => {
+ return (
+ isSupported &&
+ 'requestPermission' in DeviceMotionEvent &&
+ typeof DeviceMotionEvent.requestPermission === 'function'
+ );
+ });
+ let isPermissionGranted = $state(false);
- const _isSupported = isSupported(() => window !== undefined && 'DeviceMotionEvent' in window);
- let _acceleration = $state>({
+ let acceleration = $state>({
x: null,
y: null,
z: null
});
- let _accelerationIncludingGravity = $state<
+ let accelerationIncludingGravity = $state<
NonNullable
>({
x: null,
y: null,
z: null
});
- let _rotationRate = $state>({
+ let rotationRate = $state>({
alpha: null,
beta: null,
gamma: null
});
- let _interval = $state(0);
+ let interval = $state(0);
- if (_isSupported.current) {
- cleanup = handleEventListener('devicemotion', onDeviceMotion, { autoCleanup });
+ if (isSupported) {
+ if (requestPermissions && requiresPermissions) {
+ ensurePermissions().then(() => init());
+ } else {
+ init();
+ }
}
- if (autoCleanup) {
- onDestroy(() => cleanup());
+ function init() {
+ handleEventListener(window, 'devicemotion', onDeviceMotion, { passive: true });
}
function onDeviceMotion(event: DeviceMotionEvent) {
- if (event.acceleration) {
- _acceleration = event.acceleration;
- }
+ acceleration = {
+ x: event.acceleration?.x || null,
+ y: event.acceleration?.y || null,
+ z: event.acceleration?.z || null
+ };
- if (event.accelerationIncludingGravity) {
- _accelerationIncludingGravity = event.accelerationIncludingGravity;
- }
+ accelerationIncludingGravity = {
+ x: event.accelerationIncludingGravity?.x || null,
+ y: event.accelerationIncludingGravity?.y || null,
+ z: event.accelerationIncludingGravity?.z || null
+ };
+
+ rotationRate = {
+ alpha: event.rotationRate?.alpha || null,
+ beta: event.rotationRate?.beta || null,
+ gamma: event.rotationRate?.gamma || null
+ };
- if (event.rotationRate) {
- _rotationRate = event.rotationRate;
+ interval = event.interval;
+ }
+
+ async function ensurePermissions() {
+ if (!requiresPermissions) {
+ isPermissionGranted = true;
+ return;
}
- _interval = event.interval;
+ const requestPermission = (DeviceMotionEvent as unknown as GetDeviceMotionEventiOS)
+ .requestPermission;
+
+ try {
+ const response = await requestPermission();
+
+ if (response === 'granted') {
+ isPermissionGranted = true;
+ init();
+ }
+ } catch (error) {
+ console.error(error);
+ }
}
return {
get isSupported() {
- return _isSupported.current;
+ return isSupported;
},
get acceleration() {
- return _acceleration;
+ return acceleration;
},
get accelerationIncludingGravity() {
- return _accelerationIncludingGravity;
+ return accelerationIncludingGravity;
},
get rotationRate() {
- return _rotationRate;
+ return rotationRate;
},
get interval() {
- return _interval;
+ return interval;
+ },
+ get requiresPermissions() {
+ return requiresPermissions;
+ },
+ get isPermissionGranted() {
+ return isPermissionGranted;
},
- cleanup
+ ensurePermissions
};
}
diff --git a/packages/core/src/get-device-orientation/index.svelte.test.ts b/packages/core/src/get-device-orientation/index.svelte.test.ts
new file mode 100644
index 0000000..d801634
--- /dev/null
+++ b/packages/core/src/get-device-orientation/index.svelte.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { getDeviceOrientation } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+class MockDeviceOrientationEvent extends Event {
+ public absolute: boolean = true;
+ public alpha: number = 312;
+ public beta: number = 155;
+ public gamma: number = -80;
+}
+
+describe('getDeviceOrientation', () => {
+ it('Returns the default values if it is not supported', () => {
+ const deviceOrientation = getDeviceOrientation();
+
+ flushSync();
+
+ expect(deviceOrientation.isSupported).toBe(false);
+ expect(deviceOrientation.isAbsolute).toBe(false);
+ expect(deviceOrientation.alpha).toBe(0);
+ expect(deviceOrientation.beta).toBe(0);
+ expect(deviceOrientation.gamma).toBe(0);
+ });
+});
+
+describe('getDeviceOrientation', () => {
+ beforeEach(() => {
+ window.DeviceOrientationEvent = MockDeviceOrientationEvent;
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ window.DeviceOrientationEvent = undefined;
+ });
+
+ it("Updates correctly after a 'deviceorientation' event", () => {
+ const cleanup = $effect.root(() => {
+ const deviceOrientation = getDeviceOrientation();
+
+ flushSync();
+
+ window.dispatchEvent(
+ new DeviceOrientationEvent('deviceorientation', {
+ absolute: true,
+ alpha: 312,
+ beta: 155,
+ gamma: -80
+ })
+ );
+
+ expect(deviceOrientation.isSupported).toBe(true);
+ expect(deviceOrientation.isAbsolute).toBe(true);
+ expect(deviceOrientation.alpha).toBe(312);
+ expect(deviceOrientation.beta).toBe(155);
+ expect(deviceOrientation.gamma).toBe(-80);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-device-orientation/index.svelte.ts b/packages/core/src/get-device-orientation/index.svelte.ts
index 9418e5a..e58b6ab 100644
--- a/packages/core/src/get-device-orientation/index.svelte.ts
+++ b/packages/core/src/get-device-orientation/index.svelte.ts
@@ -1,18 +1,7 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { isSupported } from '../__internal__/is.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type GetDeviceOrientationOptions = {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-};
+type GetDeviceOrientationOptions = ConfigurableWindow;
type GetDeviceOrientationReturn = {
readonly isSupported: boolean;
@@ -24,11 +13,6 @@ type GetDeviceOrientationReturn = {
readonly beta: number;
/** The motion of the device around the y axis, express in degrees with values ranging from -90 (inclusive) to 90 (exclusive). */
readonly gamma: number;
- /**
- * Cleans up the event listener.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
};
/**
@@ -38,47 +22,41 @@ type GetDeviceOrientationReturn = {
export function getDeviceOrientation(
options: GetDeviceOrientationOptions = {}
): GetDeviceOrientationReturn {
- const { autoCleanup = true } = options;
-
- let cleanup: CleanupFunction = noop;
+ const { window = defaultWindow } = options;
- const _isSupported = isSupported(() => window && 'DeviceOrientationEvent' in window);
- let _isAbsolute = $state(false);
- let _alpha = $state(0);
- let _beta = $state(0);
- let _gamma = $state(0);
+ const isSupported = $derived(!!window && 'DeviceOrientationEvent' in window);
- if (_isSupported.current) {
- cleanup = handleEventListener('deviceorientation', onDeviceOrientation, { autoCleanup });
- }
+ let isAbsolute = $state(false);
+ let alpha = $state(0);
+ let beta = $state(0);
+ let gamma = $state(0);
- if (autoCleanup) {
- onDestroy(() => cleanup());
+ if (isSupported) {
+ handleEventListener(window, 'deviceorientation', onDeviceOrientation, { passive: true });
}
function onDeviceOrientation(event: DeviceOrientationEvent) {
- _isAbsolute = event.absolute;
- _alpha = event.alpha!;
- _beta = event.beta!;
- _gamma = event.gamma!;
+ isAbsolute = event.absolute;
+ alpha = event.alpha!;
+ beta = event.beta!;
+ gamma = event.gamma!;
}
return {
get isSupported() {
- return _isSupported.current;
+ return isSupported;
},
get isAbsolute() {
- return _isAbsolute;
+ return isAbsolute;
},
get alpha() {
- return _alpha;
+ return alpha;
},
get beta() {
- return _beta;
+ return beta;
},
get gamma() {
- return _gamma;
- },
- cleanup
+ return gamma;
+ }
};
}
diff --git a/packages/core/src/get-device-pixel-ratio/index.svelte.test.ts b/packages/core/src/get-device-pixel-ratio/index.svelte.test.ts
new file mode 100644
index 0000000..136eba4
--- /dev/null
+++ b/packages/core/src/get-device-pixel-ratio/index.svelte.test.ts
@@ -0,0 +1,78 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { getDevicePixelRatio } from './index.svelte.js';
+
+describe('getDevicePixelRatio', () => {
+ beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn()
+ }))
+ });
+ });
+
+ afterAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ value: undefined
+ });
+ });
+
+ beforeEach(() => {
+ window.devicePixelRatio = 1;
+ });
+
+ it('Returns the default values if it is not supported', () => {
+ const cleanup = $effect.root(() => {
+ const devicePixelRatio = getDevicePixelRatio({ window: {} as Window });
+
+ expect(devicePixelRatio.isSupported).toBe(false);
+ expect(devicePixelRatio.current).toBe(1);
+ });
+
+ cleanup();
+ });
+
+ it('Returns the default values if it is supported', () => {
+ const cleanup = $effect.root(() => {
+ const devicePixelRatio = getDevicePixelRatio();
+
+ expect(devicePixelRatio.isSupported).toBe(true);
+ expect(devicePixelRatio.current).toBe(1);
+ });
+
+ cleanup();
+ });
+
+ it('Returns the current value if it is different than the default one', () => {
+ const cleanup = $effect.root(() => {
+ window.devicePixelRatio = 0.83;
+
+ const devicePixelRatio = getDevicePixelRatio();
+
+ expect(devicePixelRatio.isSupported).toBe(true);
+ expect(devicePixelRatio.current).toBe(0.83);
+ });
+
+ cleanup();
+ });
+
+ it("Updates on 'change' event", () => {
+ const cleanup = $effect.root(() => {
+ const devicePixelRatio = getDevicePixelRatio();
+
+ expect(devicePixelRatio.isSupported).toBe(true);
+ expect(devicePixelRatio.current).toBe(1);
+
+ // * Cannot test because we don't have access to the media object
+ // window.devicePixelRatio = 1.33;
+ // window.dispatchEvent(new Event('change'));
+ // expect(devicePixelRatio.current).toBe(1.33);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-device-pixel-ratio/index.svelte.ts b/packages/core/src/get-device-pixel-ratio/index.svelte.ts
index bc61d26..f7ba564 100644
--- a/packages/core/src/get-device-pixel-ratio/index.svelte.ts
+++ b/packages/core/src/get-device-pixel-ratio/index.svelte.ts
@@ -1,29 +1,12 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
-import { isSupported } from '../__internal__/is.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type GetDevicePixelRatioOptions = {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-};
+type GetDevicePixelRatioOptions = ConfigurableWindow;
type GetDevicePixelRatioReturn = {
/** Whether the {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio | devicePixelRatio property} is supported or not. */
readonly isSupported: boolean;
- /** The current device pixel ratio. */
readonly current: number;
- /**
- * Cleans up the event listener.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
};
/**
@@ -33,36 +16,28 @@ type GetDevicePixelRatioReturn = {
export function getDevicePixelRatio(
options: GetDevicePixelRatioOptions = {}
): GetDevicePixelRatioReturn {
- const { autoCleanup = true } = options;
-
- let cleanup: CleanupFunction = noop;
+ const { window = defaultWindow } = options;
- const _isSupported = isSupported(() => window && 'devicePixelRatio' in window);
- let _current = $state(1);
+ const isSupported = $derived(!!window && 'devicePixelRatio' in window);
+ let current = $state(1);
- if (_isSupported.current) {
+ if (isSupported) {
updatePixelRatio();
}
- if (autoCleanup) {
- onDestroy(() => cleanup());
- }
-
function updatePixelRatio() {
- _current = window.devicePixelRatio;
- cleanup();
+ current = window!.devicePixelRatio;
- const media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
- cleanup = handleEventListener(media, 'change', updatePixelRatio, { autoCleanup, once: true });
+ const media = window!.matchMedia(`(resolution: ${window!.devicePixelRatio}dppx)`);
+ handleEventListener(media, 'change', updatePixelRatio, { once: true });
}
return {
get isSupported() {
- return _isSupported.current;
+ return isSupported;
},
get current() {
- return _current;
- },
- cleanup
+ return current;
+ }
};
}
diff --git a/packages/core/src/get-document-visibility/index.svelte.test.ts b/packages/core/src/get-document-visibility/index.svelte.test.ts
new file mode 100644
index 0000000..38bd186
--- /dev/null
+++ b/packages/core/src/get-document-visibility/index.svelte.test.ts
@@ -0,0 +1,58 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { getDocumentVisibility } from './index.svelte.js';
+
+class MockDocument extends EventTarget {
+ visibilityState = 'hidden';
+
+ dispatchEvent(event: Event): boolean {
+ super.dispatchEvent(event);
+ return true;
+ }
+}
+
+describe('getDocumentVisibility', () => {
+ let mockDocument: MockDocument;
+
+ beforeEach(() => {
+ mockDocument = new MockDocument();
+ });
+
+ it("Is 'visible' by default if document is undefined", () => {
+ const cleanup = $effect.root(() => {
+ const documentVisibility = getDocumentVisibility();
+
+ expect(documentVisibility.current).toBe('visible');
+ });
+
+ cleanup();
+ });
+
+ it('Matches document.visibilityState if document is defined', () => {
+ const cleanup = $effect.root(() => {
+ const documentVisibility = getDocumentVisibility({
+ document: mockDocument as Document
+ });
+
+ expect(documentVisibility.current).toBe('hidden');
+ });
+
+ cleanup();
+ });
+
+ it("Changes when 'visibilitychange' event is fired", () => {
+ const cleanup = $effect.root(() => {
+ const documentVisibility = getDocumentVisibility({
+ document: mockDocument as Document
+ });
+
+ expect(documentVisibility.current).toBe('hidden');
+
+ mockDocument.visibilityState = 'visible';
+ mockDocument.dispatchEvent(new Event('visibilitychange'));
+
+ expect(documentVisibility.current).toBe('visible');
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-document-visibility/index.svelte.ts b/packages/core/src/get-document-visibility/index.svelte.ts
index 9c58f34..04268f0 100644
--- a/packages/core/src/get-document-visibility/index.svelte.ts
+++ b/packages/core/src/get-document-visibility/index.svelte.ts
@@ -1,52 +1,38 @@
-import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
import { defaultDocument, type ConfigurableDocument } from '../__internal__/configurable.js';
-import type { CleanupFunction } from '../__internal__/types.js';
-
-interface GetDocumentVisibilityOptions extends ConfigurableDocument {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-}
+
+type GetDocumentVisibilityOptions = ConfigurableDocument;
type GetDocumentVisibilityReturn = {
readonly current: DocumentVisibilityState;
- /**
- * Cleans up the event listener.
- * @note Is called automatically if `options.autoCleanup` is `true`.
- */
- cleanup: CleanupFunction;
};
/**
* Whether the document is visible or not.
+ * @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-document-visibility
*/
export function getDocumentVisibility(
options: GetDocumentVisibilityOptions = {}
): GetDocumentVisibilityReturn {
- const { autoCleanup = true, document = defaultDocument } = options;
+ const { document = defaultDocument } = options;
- let cleanup: CleanupFunction = noop;
let _current = $state(document?.visibilityState ?? 'visible');
if (document) {
- cleanup = handleEventListener(
- document,
- 'visibilitychange',
- () => (_current = document.visibilityState),
- { autoCleanup }
- );
+ document.addEventListener('visibilitychange', onVisibilityChange, {
+ passive: true
+ });
+ }
+
+ function onVisibilityChange() {
+ if (!document) return;
+
+ _current = document.visibilityState;
}
return {
get current() {
return _current;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/get-element-size/index.svelte.ts b/packages/core/src/get-element-size/index.svelte.ts
index d25b4d8..eda4a1d 100644
--- a/packages/core/src/get-element-size/index.svelte.ts
+++ b/packages/core/src/get-element-size/index.svelte.ts
@@ -9,7 +9,7 @@ export interface ElementSize {
height: number;
}
-interface GetElementSizeOptions extends Omit {
+interface GetElementSizeOptions extends ObserveResizeOptions {
/**
* The initial size of the element.
* @default { width: 0, height: 0 }
diff --git a/packages/core/src/get-fps/index.svelte.test.ts b/packages/core/src/get-fps/index.svelte.test.ts
new file mode 100644
index 0000000..63c6b3e
--- /dev/null
+++ b/packages/core/src/get-fps/index.svelte.test.ts
@@ -0,0 +1,31 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { getFps } from './index.svelte.js';
+
+const WINDOW_PERFORMANCE = window.performance;
+
+describe('getFps', () => {
+ beforeAll(() => {
+ // @ts-ignore
+ window.performance = undefined;
+ });
+
+ afterAll(() => {
+ window.performance = WINDOW_PERFORMANCE;
+ });
+
+ it('Returns 0 if the Performance API is not supported', () => {
+ const fps = getFps();
+
+ expect(fps.current).toBe(0);
+ });
+});
+
+describe('getFps', () => {
+ it('Returns a value bigger than 0 if the Performance API is supported', async () => {
+ const fps = getFps();
+
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ expect(fps.current).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/core/src/get-fps/index.svelte.ts b/packages/core/src/get-fps/index.svelte.ts
index 2017c5d..bbfef25 100644
--- a/packages/core/src/get-fps/index.svelte.ts
+++ b/packages/core/src/get-fps/index.svelte.ts
@@ -1,48 +1,51 @@
-import { BROWSER } from 'esm-env';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type GetFpsOptions = {
+interface GetFpsOptions extends ConfigurableWindow {
/** Re-calculate the frames per second every `x` frames. */
every?: number;
-};
+}
type GetFpsReturn = {
readonly current: number;
};
/**
+ * Returns the current frames per second.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-fps
*/
export function getFps(options: GetFpsOptions = {}): GetFpsReturn {
- const { every = 10 } = options;
+ const { every = 10, window = defaultWindow } = options;
- let _fps = $state(0);
+ let current = $state(0);
- let last = performance.now();
- let ticks = 0;
+ if (window && typeof performance !== 'undefined') {
+ let last = performance.now();
+ let ticks = 0;
- if (BROWSER) {
window.requestAnimationFrame(callback);
- }
- function callback() {
- ticks += 1;
+ function callback() {
+ if (!window) return;
- if (ticks >= every) {
- const now = performance.now();
- const delta = now - last;
+ ticks += 1;
- _fps = Math.round(1000 / (delta / ticks));
+ if (ticks >= every) {
+ const now = performance.now();
+ const delta = now - last;
- last = now;
- ticks = 0;
- }
+ current = Math.round(1000 / (delta / ticks));
- window.requestAnimationFrame(callback);
+ last = now;
+ ticks = 0;
+ }
+
+ window.requestAnimationFrame(callback);
+ }
}
return {
get current() {
- return _fps;
+ return current;
}
};
}
diff --git a/packages/core/src/get-geolocation/index.svelte.ts b/packages/core/src/get-geolocation/index.svelte.ts
index c42215e..ba9d926 100644
--- a/packages/core/src/get-geolocation/index.svelte.ts
+++ b/packages/core/src/get-geolocation/index.svelte.ts
@@ -1,13 +1,6 @@
import { onDestroy } from 'svelte';
interface GetGeolocationOptions extends Partial {
- /**
- * Whether to auto-cleanup the geolocation service or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
/**
* Whether to start the geolocation service on creation or not.
* @default true
@@ -46,11 +39,11 @@ export function getGeolocation(options: GetGeolocationOptions = {}): GetGeolocat
enableHighAccuracy = true,
maximumAge = 30000,
timeout = 27000,
- autoCleanup = true,
immediate = true
} = options;
const _isSupported = $derived.by(() => navigator && 'geolocation' in navigator);
+
let _watcherId = $state();
let _coords = $state>({
accuracy: 0,
@@ -68,10 +61,6 @@ export function getGeolocation(options: GetGeolocationOptions = {}): GetGeolocat
resume();
}
- if (autoCleanup) {
- onDestroy(() => pause());
- }
-
function resume() {
if (!_isSupported) return;
diff --git a/packages/core/src/get-last-changed/index.svelte.test.ts b/packages/core/src/get-last-changed/index.svelte.test.ts
new file mode 100644
index 0000000..6ccd83a
--- /dev/null
+++ b/packages/core/src/get-last-changed/index.svelte.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { getLastChanged } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+function toSeconds(ms: number) {
+ return Math.floor(ms / 1000);
+}
+
+describe('getLastChanged', () => {
+ let counter = $state()!;
+
+ beforeEach(() => {
+ counter = 0;
+ });
+
+ it('Is equal to now by default', () => {
+ const cleanup = $effect.root(() => {
+ const lastChanged = getLastChanged(() => counter);
+
+ flushSync();
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now()));
+ });
+
+ cleanup();
+ });
+
+ it('Stays the same after some time has passed', () => {
+ const cleanup = $effect.root(() => {
+ const lastChanged = getLastChanged(() => counter);
+
+ flushSync();
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now()));
+
+ vi.setSystemTime(Date.now() + 10000);
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now() - 10000));
+ });
+
+ cleanup();
+ });
+
+ it('Updates correctly when the state changes', () => {
+ const cleanup = $effect.root(() => {
+ const lastChanged = getLastChanged(() => counter);
+
+ flushSync();
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now()));
+
+ vi.setSystemTime(Date.now() + 10000);
+
+ flushSync(() => {
+ counter += 1;
+ });
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now()));
+ });
+
+ cleanup();
+ });
+
+ it("Is 'null' by default if options.immediate is false", () => {
+ const cleanup = $effect.root(() => {
+ const lastChanged = getLastChanged(() => counter, { immediate: false });
+
+ flushSync();
+
+ expect(lastChanged.current).toBeNull();
+
+ flushSync(() => {
+ counter += 1;
+ });
+
+ expect(toSeconds(lastChanged.current!)).toBe(toSeconds(Date.now()));
+ });
+
+ cleanup();
+ });
+
+ it('Does not watch for deep changes if options.deep is false', () => {
+ const cleanup = $effect.root(() => {
+ let counters = $state([]);
+ const lastChanged = getLastChanged(() => counters, { deep: false });
+
+ flushSync();
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now()));
+
+ vi.setSystemTime(Date.now() + 10000);
+
+ flushSync(() => {
+ counters.push(1);
+ });
+
+ expect(toSeconds(lastChanged.current)).toBe(toSeconds(Date.now() - 10000));
+
+ vi.setSystemTime(Date.now() + 10000);
+
+ flushSync(() => {
+ counters = [1];
+ });
+
+ expect(toSeconds(lastChanged.current!)).toBe(toSeconds(Date.now()));
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-last-changed/index.svelte.ts b/packages/core/src/get-last-changed/index.svelte.ts
index e39848e..5d5efc2 100644
--- a/packages/core/src/get-last-changed/index.svelte.ts
+++ b/packages/core/src/get-last-changed/index.svelte.ts
@@ -1,20 +1,57 @@
+import type { MaybeGetter } from '../__internal__/types.js';
+import { normalizeValue } from '../__internal__/utils.svelte.js';
+import { watch } from '../watch/index.svelte.js';
+
+type GetLastChangedOptions = {
+ /**
+ * Whether to watch for deep changes on objects or in-place updates on lists (eg. push) or not.
+ * @default true
+ */
+ deep?: boolean;
+ /**
+ * Whether to set the `current` property immediately or not.
+ *
+ * If `false`, the `current` property will be null until the state changes for the first time.
+ * @default true
+ */
+ immediate?: Immediate;
+};
+
+type GetLastChangedReturn = {
+ readonly current: T;
+};
+
+export function getLastChanged(value: MaybeGetter): GetLastChangedReturn;
+
+export function getLastChanged(
+ value: MaybeGetter,
+ options?: GetLastChangedOptions
+): Immediate extends true ? GetLastChangedReturn : GetLastChangedReturn;
+
/**
* Get the last time a state changed.
* @param value The state to track as a getter function.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-last-changed
*/
-export function getLastChanged(value: () => T) {
- let _lastChanged = $state(0);
+export function getLastChanged(
+ value: MaybeGetter,
+ options: GetLastChangedOptions = {}
+): GetLastChangedReturn | GetLastChangedReturn {
+ const { deep = true, immediate = true } = options;
- $effect(() => {
- value();
+ let current = $state(null);
- _lastChanged = Date.now();
- });
+ watch(
+ () => normalizeValue(value),
+ () => {
+ current = Date.now();
+ },
+ { deep, immediate }
+ );
return {
get current() {
- return _lastChanged;
+ return current;
}
};
}
diff --git a/packages/core/src/get-mouse-pressed/index.svelte.ts b/packages/core/src/get-mouse-pressed/index.svelte.ts
index d6639d2..41c62c4 100644
--- a/packages/core/src/get-mouse-pressed/index.svelte.ts
+++ b/packages/core/src/get-mouse-pressed/index.svelte.ts
@@ -1,6 +1,5 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { defaultWindow } from '../__internal__/configurable.js';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
import type { CleanupFunction } from '../__internal__/types.js';
type GetMousePressedPressAndReleaseEvent<
@@ -14,14 +13,8 @@ type GetMousePressedPressAndReleaseEvent<
? MouseEvent | DragEvent
: MouseEvent;
-type GetMousePressedOptions = {
- /**
- * Whether to auto-cleanup the event listeners or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
+interface GetMousePressedOptions
+ extends ConfigurableWindow {
/**
* Only trigger if the click happened inside `target`.
* @default window
@@ -47,18 +40,13 @@ type GetMousePressedOptions {}
*/
onReleased?: (event: GetMousePressedPressAndReleaseEvent) => void;
-};
+}
type GetMousePressedType = 'mouse' | 'touch' | null;
type GetMousePressedReturn = {
readonly isPressed: boolean;
readonly type: GetMousePressedType;
- /**
- * Cleans up the event listeners.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
};
/**
@@ -71,69 +59,31 @@ export function getMousePressed<
EnableDrag extends boolean = true
>(options: GetMousePressedOptions = {}): GetMousePressedReturn {
const {
- autoCleanup = true,
target = defaultWindow,
+ window = defaultWindow,
enableTouch = true,
enableDrag = true,
onPressed = () => {},
onReleased = () => {}
} = options;
- const cleanups: CleanupFunction[] = [];
-
let _isPressed = $state(false);
let _type = $state(null);
- if (target) {
- cleanups.push(
- handleEventListener(target, 'mousedown', _onPressed('mouse'), {
- passive: true
- }),
-
- handleEventListener(window, 'mouseleave', _onReleased, {
- autoCleanup,
- passive: true
- }),
- handleEventListener(window, 'mouseup', _onReleased, {
- autoCleanup,
- passive: true
- })
- );
+ handleEventListener(target, 'mousedown', _onPressed('mouse'), { passive: true });
+ handleEventListener(window, 'mouseleave', _onReleased, { passive: true });
+ handleEventListener(window, 'mouseup', _onReleased, { passive: true });
- if (enableDrag) {
- cleanups.push(
- handleEventListener(target, 'dragstart', _onPressed('mouse'), {
- passive: true
- }),
-
- handleEventListener(window, 'drop', _onReleased, { autoCleanup, passive: true }),
- handleEventListener(window, 'dragend', _onReleased, {
- autoCleanup,
- passive: true
- })
- );
- }
-
- if (enableTouch) {
- cleanups.push(
- handleEventListener(target, 'touchstart', _onPressed('touch'), {
- passive: true
- }),
-
- handleEventListener(window, 'touchend', _onReleased, {
- autoCleanup,
- passive: true
- }),
- handleEventListener(window, 'touchcancel', _onReleased, {
- autoCleanup,
- passive: true
- })
- );
- }
+ if (enableDrag) {
+ handleEventListener(target, 'dragstart', _onPressed('mouse'), { passive: true });
+ handleEventListener(window, 'drop', _onReleased, { passive: true });
+ handleEventListener(window, 'dragend', _onReleased, { passive: true });
}
- if (autoCleanup) {
- onDestroy(() => cleanup());
+ if (enableTouch) {
+ handleEventListener(target, 'touchstart', _onPressed('touch'), { passive: true });
+ handleEventListener(window, 'touchend', _onReleased, { passive: true });
+ handleEventListener(window, 'touchcancel', _onReleased, { passive: true });
}
function _onPressed(type: GetMousePressedType) {
@@ -154,17 +104,12 @@ export function getMousePressed<
onReleased(event);
}
- function cleanup() {
- cleanups.forEach((fn) => fn());
- }
-
return {
get isPressed() {
return _isPressed;
},
get type() {
return _type;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/get-mouse/index.svelte.ts b/packages/core/src/get-mouse/index.svelte.ts
index 3538baf..8901791 100644
--- a/packages/core/src/get-mouse/index.svelte.ts
+++ b/packages/core/src/get-mouse/index.svelte.ts
@@ -1,17 +1,8 @@
-import { onDestroy } from 'svelte';
-import { BROWSER } from 'esm-env';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
import { noop } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction } from '../__internal__/types.js';
+import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-type GetMouseOptions = {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
+interface GetMouseOptions extends ConfigurableWindow {
/**
* The initial position of the mouse.
* @default { x: 0; y: 0 }
@@ -22,18 +13,13 @@ type GetMouseOptions = {
* @default () => {}
*/
onMove?: (event: MouseEvent) => void;
-};
+}
type GetMouseReturn = {
/** The horizontal position of the mouse. */
readonly x: number;
/** The vertical position of the mouse. */
readonly y: number;
- /**
- * Cleans up the event listener.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
};
/**
@@ -42,20 +28,12 @@ type GetMouseReturn = {
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-mouse
*/
export function getMouse(options: GetMouseOptions = {}): GetMouseReturn {
- const { autoCleanup = true, initial = { x: 0, y: 0 }, onMove = noop } = options;
-
- let cleanup: CleanupFunction = noop;
+ const { initial = { x: 0, y: 0 }, onMove = noop, window = defaultWindow } = options;
let _x = $state(initial.x);
let _y = $state(initial.y);
- if (BROWSER) {
- cleanup = handleEventListener('mousemove', onMouseMove, { autoCleanup });
- }
-
- if (autoCleanup) {
- onDestroy(() => cleanup());
- }
+ handleEventListener(window, 'mousemove', onMouseMove);
function onMouseMove(event: MouseEvent) {
_x = event.pageX;
@@ -70,7 +48,6 @@ export function getMouse(options: GetMouseOptions = {}): GetMouseReturn {
},
get y() {
return _y;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/get-network/index.svelte.test.ts b/packages/core/src/get-network/index.svelte.test.ts
new file mode 100644
index 0000000..d6193c7
--- /dev/null
+++ b/packages/core/src/get-network/index.svelte.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest';
+import { getNetwork, type NetworkInformation } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+class MockNavigatorConnection extends EventTarget implements NetworkInformation {
+ public downlink = 5;
+ public downlinkMax = 10;
+ public effectiveType: NetworkInformation['effectiveType'] = '3g';
+ public rtt = 50;
+ public saveData = true;
+ public type: NetworkInformation['type'] = 'cellular';
+}
+
+class MockNavigator {
+ public connection = new MockNavigatorConnection();
+}
+
+describe('getNetwork', () => {
+ let mockNavigator = new MockNavigator();
+
+ it('Returns default values if it is not supported', () => {
+ const cleanup = $effect.root(() => {
+ const network = getNetwork();
+
+ flushSync();
+
+ expect(network.downlink).toBe(0);
+ expect(network.downlinkMax).toBe(0);
+ expect(network.effectiveType).toBe('slow-2g');
+ expect(network.rtt).toBe(0);
+ expect(network.saveData).toBe(false);
+ expect(network.type).toBe('unknown');
+ });
+
+ cleanup();
+ });
+
+ it('Returns correct values if it is supported', () => {
+ const cleanup = $effect.root(() => {
+ const network = getNetwork({ navigator: mockNavigator as unknown as Navigator });
+
+ flushSync();
+
+ expect(network.downlink).toBe(5);
+ expect(network.downlinkMax).toBe(10);
+ expect(network.effectiveType).toBe('3g');
+ expect(network.rtt).toBe(50);
+ expect(network.saveData).toBe(true);
+ expect(network.type).toBe('cellular');
+ });
+
+ cleanup();
+ });
+
+ it("Updates on 'change' event", () => {
+ const cleanup = $effect.root(() => {
+ const network = getNetwork({ navigator: mockNavigator as unknown as Navigator });
+
+ flushSync();
+
+ expect(network.downlink).toBe(5);
+ expect(network.downlinkMax).toBe(10);
+ expect(network.effectiveType).toBe('3g');
+ expect(network.rtt).toBe(50);
+ expect(network.saveData).toBe(true);
+ expect(network.type).toBe('cellular');
+
+ mockNavigator.connection.downlink = 20;
+ mockNavigator.connection.downlinkMax = 100;
+ mockNavigator.connection.effectiveType = '4g';
+ mockNavigator.connection.rtt = 15;
+ mockNavigator.connection.saveData = false;
+ mockNavigator.connection.type = 'wifi';
+
+ mockNavigator.connection.dispatchEvent(new Event('change'));
+
+ expect(network.downlink).toBe(20);
+ expect(network.downlinkMax).toBe(100);
+ expect(network.effectiveType).toBe('4g');
+ expect(network.rtt).toBe(15);
+ expect(network.saveData).toBe(false);
+ expect(network.type).toBe('wifi');
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/get-network/index.svelte.ts b/packages/core/src/get-network/index.svelte.ts
index b6f9972..8327135 100644
--- a/packages/core/src/get-network/index.svelte.ts
+++ b/packages/core/src/get-network/index.svelte.ts
@@ -1,62 +1,99 @@
-import { BROWSER } from 'esm-env';
+import { defaultNavigator, type ConfigurableNavigator } from '../__internal__/configurable.js';
+import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-type NetworkInformation = {
+type NetworkEffectiveType = 'slow-2g' | '2g' | '3g' | '4g';
+type NetworkType =
+ | 'bluetooth'
+ | 'cellular'
+ | 'ethernet'
+ | 'none'
+ | 'wifi'
+ | 'wimax'
+ | 'other'
+ | 'unknown';
+
+export interface NetworkInformation {
/** The effective bandwidth estimate in megabits per second, rounded to the nearest multiple of 25 kilobits per seconds. */
readonly downlink: number;
/** The maximum downlink speed, in megabits per second (Mbps), for the underlying connection technology. */
readonly downlinkMax: number;
/** @see https://developer.mozilla.org/en-US/docs/Glossary/Effective_connection_type */
- readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
+ readonly effectiveType: NetworkEffectiveType;
/** The estimated effective round-trip time of the current connection, rounded to the nearest multiple of 25 milliseconds. */
readonly rtt: number;
/** Whether the user has set a reduced data usage option on the user agent or not. */
readonly saveData: boolean;
/** The type of connection a device is using to communicate with the network. */
- readonly type:
- | 'bluetooth'
- | 'cellular'
- | 'ethernet'
- | 'none'
- | 'wifi'
- | 'wimax'
- | 'other'
- | 'unknown';
-};
+ readonly type: NetworkType;
+}
+
+interface NavigatorConnection extends NetworkInformation, EventTarget {}
type NavigatorWithConnection = Navigator & {
- readonly connection: NetworkInformation;
+ readonly connection: NavigatorConnection;
};
-type GetNetworkReturn = {
+type GetNetworkOptions = ConfigurableNavigator;
+
+interface GetNetworkReturn extends NetworkInformation {
readonly isSupported: boolean;
- readonly current: NetworkInformation;
-};
+}
/**
* Provides information about the connection a device is using to communicate with the network.
+ * @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-network
*/
-export function getNetwork(): GetNetworkReturn {
- const _isSupported = $derived.by(() => navigator && 'connection' in navigator);
- let _current = $state({
- downlink: 0,
- downlinkMax: 0,
- effectiveType: 'slow-2g',
- rtt: 0,
- saveData: false,
- type: 'unknown'
+export function getNetwork(options: GetNetworkOptions = {}): GetNetworkReturn {
+ const { navigator = defaultNavigator } = options;
+
+ const isSupported = $derived(!!navigator && 'connection' in navigator);
+ const connection = $derived.by(() => {
+ return (isSupported || undefined) && (navigator as NavigatorWithConnection).connection;
});
- if (BROWSER && _isSupported) {
- _current = { ..._current, ...(navigator as NavigatorWithConnection).connection };
+ let downlink = $state(0);
+ let downlinkMax = $state(0);
+ let effectiveType = $state('slow-2g');
+ let rtt = $state(0);
+ let saveData = $state(false);
+ let type = $state('unknown');
+
+ updateNetworkInformation();
+ handleEventListener(connection, 'change', updateNetworkInformation, { passive: true });
+
+ function updateNetworkInformation() {
+ if (!navigator || !connection) return;
+
+ downlink = connection.downlink;
+ downlinkMax = connection.downlinkMax;
+ effectiveType = connection.effectiveType;
+ rtt = connection.rtt;
+ saveData = connection.saveData;
+ type = connection.type;
}
return {
get isSupported() {
- return _isSupported;
+ return isSupported;
+ },
+ get downlink() {
+ return downlink;
+ },
+ get downlinkMax() {
+ return downlinkMax;
+ },
+ get effectiveType() {
+ return effectiveType;
+ },
+ get rtt() {
+ return rtt;
+ },
+ get saveData() {
+ return saveData;
},
- get current() {
- return _current;
+ get type() {
+ return type;
}
};
}
diff --git a/packages/core/src/get-permission/index.svelte.test.ts b/packages/core/src/get-permission/index.svelte.test.ts
new file mode 100644
index 0000000..df9987e
--- /dev/null
+++ b/packages/core/src/get-permission/index.svelte.test.ts
@@ -0,0 +1,138 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { getPermission } from './index.svelte.js';
+import { flushSync } from 'svelte';
+import { asyncEffectRoot } from '../__internal__/utils.svelte.js';
+
+describe('getPermission - Not supported', () => {
+ it("Returns \'prompt\' by default", () => {
+ const cleanup = $effect.root(() => {
+ const permission = getPermission('camera');
+
+ flushSync();
+
+ expect(permission.isSupported).toBeFalsy();
+ expect(permission.current).toBe('prompt');
+ });
+
+ cleanup();
+ });
+
+ it('Does nothing when querying', async () => {
+ const cleanup = asyncEffectRoot(async () => {
+ const permission = getPermission('camera');
+
+ flushSync();
+
+ await permission.query();
+
+ expect(permission.isSupported).toBeFalsy();
+ expect(permission.current).toBe('prompt');
+ });
+
+ await cleanup();
+ });
+});
+
+class MockPermissionStatus extends EventTarget {
+ constructor(
+ public name: string,
+ public state: PermissionState
+ ) {
+ super();
+ }
+
+ onchange: ((this: PermissionStatus, ev: Event) => any) | null = null;
+}
+
+class MockPermissions implements Permissions {
+ query(permissionDesc: PermissionDescriptor): Promise {
+ return new Promise((resolve) =>
+ resolve(new MockPermissionStatus(permissionDesc.name, 'granted'))
+ );
+ }
+}
+
+describe('getPermission - Supported', () => {
+ beforeEach(() => {
+ Object.defineProperty(navigator, 'permissions', {
+ get: () => new MockPermissions(),
+ configurable: true
+ });
+ });
+
+ it("Returns 'prompt' if it is supported", async () => {
+ const cleanup = asyncEffectRoot(async () => {
+ const permission = getPermission('camera');
+
+ flushSync();
+
+ expect(permission.isSupported).toBeTruthy();
+ expect(permission.current).toBe('prompt');
+ });
+
+ await cleanup();
+ });
+
+ it('Returns the new status if queried', async () => {
+ const cleanup = asyncEffectRoot(async () => {
+ const permission = getPermission('camera');
+
+ flushSync();
+
+ await permission.query();
+
+ expect(permission.isSupported).toBeTruthy();
+ expect(permission.current).toBe('granted');
+ });
+
+ await cleanup();
+ });
+
+ it('Works with a descriptor instead of a name', async () => {
+ const cleanup = asyncEffectRoot(async () => {
+ const permission = getPermission({ name: 'camera' });
+
+ flushSync();
+
+ await permission.query();
+
+ expect(permission.isSupported).toBeTruthy();
+ expect(permission.current).toBe('granted');
+ });
+
+ await cleanup();
+ });
+});
+
+describe('getPermission - Supported', () => {
+ beforeEach(() => {
+ Object.defineProperty(navigator, 'permissions', {
+ get: () => {
+ return {
+ query: () => {
+ throw Error('An error occured');
+ }
+ };
+ },
+ configurable: true
+ });
+ });
+
+ it("Resets to 'prompt' if `query` returns undefined", async () => {
+ const cleanup = asyncEffectRoot(async () => {
+ const permission = getPermission({ name: 'camera' });
+
+ flushSync();
+
+ expect(permission.isSupported).toBeTruthy();
+ expect(permission.current).toBe('prompt');
+
+ await permission.query();
+
+ expect(permission.isSupported).toBeTruthy();
+ expect(permission.current).toBe('prompt');
+ });
+
+ await cleanup();
+ });
+});
diff --git a/packages/core/src/get-permission/index.svelte.ts b/packages/core/src/get-permission/index.svelte.ts
index 2c036ff..bb6f0aa 100644
--- a/packages/core/src/get-permission/index.svelte.ts
+++ b/packages/core/src/get-permission/index.svelte.ts
@@ -1,89 +1,69 @@
-import { onMount } from 'svelte';
+import { handleEventListener } from '../handle-event-listener/index.svelte.js';
+import { createSingletonPromise } from '../__internal__/utils.svelte.js';
+import { defaultNavigator, type ConfigurableNavigator } from '../__internal__/configurable.js';
+import type { ExtendedPermissionDescriptor, ExtendedPermissionName } from './types.js';
-type ExtendedPermissionName =
- | PermissionName
- | 'accelerometer'
- | 'accessibility-events'
- | 'ambient-light-sensor'
- | 'background-sync'
- | 'camera'
- | 'clipboard-read'
- | 'clipboard-write'
- | 'gyroscope'
- | 'magnetometer'
- | 'microphone'
- | 'payment-handler'
- | 'speaker'
- | 'local-fonts';
+type GetPermissionOptions = ConfigurableNavigator;
-export type ExtendedPermissionDescriptor = PermissionDescriptor | { name: ExtendedPermissionName };
-
-type GetPermissionOptions = {
- exposeControls?: ExposeControls;
-};
-
-type GetPermissionReturn = Readonly;
-
-type GetPermissionReturnWithControls = {
+type GetPermissionReturn = {
readonly isSupported: boolean;
readonly current: PermissionState;
- query: () => Promise;
+ query: () => Promise;
};
-export function getPermission(
- nameOrDesc: ExtendedPermissionName | ExtendedPermissionDescriptor
-): GetPermissionReturn;
-
-export function getPermission(
- nameOrDesc: ExtendedPermissionName | ExtendedPermissionDescriptor,
- options: GetPermissionOptions
-): ExposeControls extends true ? GetPermissionReturnWithControls : GetPermissionReturn;
-
/**
- * Retrieves the status of a given permission.
+ * Queries and retrieves the state of a given permission.
+ * @param nameOrDesc The permission whose status to retrieve.
+ * @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/get-permission
*/
-export function getPermission(
+export function getPermission(
nameOrDesc: ExtendedPermissionName | ExtendedPermissionDescriptor,
- options: GetPermissionOptions = {}
-): GetPermissionReturn | GetPermissionReturnWithControls {
- const { exposeControls = false } = options;
+ options: GetPermissionOptions = {}
+): GetPermissionReturn {
+ const { navigator = defaultNavigator } = options;
- const _descriptor = typeof nameOrDesc === 'string' ? { name: nameOrDesc } : nameOrDesc;
- let _current = $state('prompt');
- let _isSupported = $state(false);
+ const descriptor =
+ typeof nameOrDesc === 'string'
+ ? ({ name: nameOrDesc } as PermissionDescriptor)
+ : (nameOrDesc as PermissionDescriptor);
- function query(): Promise {
- return navigator.permissions.query(_descriptor as PermissionDescriptor);
- }
+ const isSupported = $derived(!!navigator && 'permissions' in navigator);
+
+ let permissionStatus = $state();
+ let state = $state('prompt');
+
+ handleEventListener(() => permissionStatus, 'change', update, { passive: true });
- onMount(async () => {
- if (!('permissions' in navigator)) return;
+ function update() {
+ state = permissionStatus?.state ?? 'prompt';
+ }
- try {
- const status = await query();
- _isSupported = true;
- _current = status.state;
+ const query = createSingletonPromise(async () => {
+ if (!isSupported) return;
- status.onchange = async () => {
- _current = (await query()).state;
- };
- } catch {
- /* empty */
+ if (!permissionStatus) {
+ try {
+ permissionStatus = await navigator!.permissions.query(descriptor);
+ } catch {
+ permissionStatus = undefined;
+ } finally {
+ update();
+ }
}
+
+ return permissionStatus;
});
- if (exposeControls) {
- return {
- get isSupported() {
- return _isSupported;
- },
- get current() {
- return _current;
- },
- query
- };
- } else {
- return _current;
- }
+ query();
+
+ return {
+ get isSupported() {
+ return isSupported;
+ },
+ get current() {
+ return state;
+ },
+ query
+ };
}
diff --git a/packages/core/src/get-permission/types.ts b/packages/core/src/get-permission/types.ts
new file mode 100644
index 0000000..8929935
--- /dev/null
+++ b/packages/core/src/get-permission/types.ts
@@ -0,0 +1,17 @@
+export type ExtendedPermissionName =
+ | PermissionName
+ | 'accelerometer'
+ | 'accessibility-events'
+ | 'ambient-light-sensor'
+ | 'background-sync'
+ | 'camera'
+ | 'clipboard-read'
+ | 'clipboard-write'
+ | 'gyroscope'
+ | 'magnetometer'
+ | 'microphone'
+ | 'payment-handler'
+ | 'speaker'
+ | 'local-fonts';
+
+export type ExtendedPermissionDescriptor = PermissionDescriptor | { name: ExtendedPermissionName };
diff --git a/packages/core/src/get-previous/index.svelte.ts b/packages/core/src/get-previous/index.svelte.ts
index 62abb50..6a4a783 100644
--- a/packages/core/src/get-previous/index.svelte.ts
+++ b/packages/core/src/get-previous/index.svelte.ts
@@ -27,10 +27,11 @@ export function getPrevious(
const _previous = $state({ current: initial });
watch(
- () => $state.snapshot(getter()) as T,
+ () => getter(),
(_, prev) => {
_previous.current = prev;
- }
+ },
+ { deep: true }
);
return _previous;
diff --git a/packages/core/src/get-scrollbar-width/index.svelte.ts b/packages/core/src/get-scrollbar-width/index.svelte.ts
index 4b08f5d..f42c690 100644
--- a/packages/core/src/get-scrollbar-width/index.svelte.ts
+++ b/packages/core/src/get-scrollbar-width/index.svelte.ts
@@ -1,14 +1,13 @@
-import { untrack } from 'svelte';
+import { BROWSER } from 'esm-env';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
import { observeResize } from '../observe-resize/index.svelte.js';
import { observeMutation } from '../observe-mutation/index.svelte.js';
import { normalizeValue } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction, MaybeGetter } from '../__internal__/types.js';
+import type { MaybeGetter } from '../__internal__/types.js';
type GetScrollbarWidthReturn = {
readonly x: number;
readonly y: number;
- cleanup: CleanupFunction;
};
/**
@@ -19,37 +18,25 @@ type GetScrollbarWidthReturn = {
export function getScrollbarWidth(
element: MaybeGetter
): GetScrollbarWidthReturn {
- let cleanups: CleanupFunction[] = [];
+ let x = $state(0);
+ let y = $state(0);
- let x = $state(0);
- let y = $state(0);
- const _target = $derived(normalizeValue(element));
+ handleEventListener(element, 'resize', calculate);
- $effect(() => untrack(() => calculate()));
+ observeResize(element, calculate);
+ observeMutation(element, calculate, { attributes: true });
- $effect(() => {
- if (_target) {
- cleanups.push(handleEventListener('resize', calculate));
- }
-
- return cleanup;
- });
-
- cleanups.push(
- observeResize(() => _target, calculate, { autoCleanup: false }).cleanup,
- observeMutation(() => _target, calculate, { autoCleanup: false, attributes: true }).cleanup
- );
+ if (BROWSER) {
+ calculate();
+ }
function calculate() {
- if (!_target) return;
+ const _element = normalizeValue(element);
- x = _target.offsetWidth - _target.clientWidth;
- y = _target.offsetHeight - _target.clientHeight;
- }
+ if (!_element) return;
- function cleanup() {
- cleanups.forEach((fn) => fn());
- cleanups = [];
+ x = _element.offsetWidth - _element.clientWidth;
+ y = _element.offsetHeight - _element.clientHeight;
}
return {
@@ -58,7 +45,6 @@ export function getScrollbarWidth(
},
get y() {
return y;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/get-text-direction/index.svelte.test.ts b/packages/core/src/get-text-direction/index.svelte.test.ts
index ae1d4bd..afaeb49 100644
--- a/packages/core/src/get-text-direction/index.svelte.test.ts
+++ b/packages/core/src/get-text-direction/index.svelte.test.ts
@@ -5,7 +5,7 @@ import { flushSync } from 'svelte';
describe('getTextDirection', () => {
it("has a default value of 'ltr'", () => {
const cleanup = $effect.root(() => {
- const dir = getTextDirection({ autoCleanup: false });
+ const dir = getTextDirection();
expect(dir.current).toBe('ltr');
});
@@ -15,7 +15,7 @@ describe('getTextDirection', () => {
it("takes 'initial' as default value if passed", () => {
const cleanup = $effect.root(() => {
- const dir = getTextDirection({ initial: 'rtl', autoCleanup: false });
+ const dir = getTextDirection({ initial: 'rtl' });
expect(dir.current).toBe('rtl');
});
@@ -26,7 +26,7 @@ describe('getTextDirection', () => {
it('reflects the changes on the dom when current is set', () => {
const cleanup = $effect.root(() => {
const element = $state(document.createElement('div'));
- const dir = getTextDirection({ element: () => element, autoCleanup: false });
+ const dir = getTextDirection({ element: () => element });
expect(element.getAttribute('dir')).toBeNull();
@@ -41,7 +41,7 @@ describe('getTextDirection', () => {
it('observes the changes in the dom', () => {
const cleanup = $effect.root(() => {
const element = $state(document.createElement('div'));
- const dir = getTextDirection({ element: () => element, observe: true, autoCleanup: false });
+ const dir = getTextDirection({ element: () => element, observe: true });
expect(dir.current).toBe('ltr');
expect(element.getAttribute('dir')).toBeNull();
diff --git a/packages/core/src/get-text-direction/index.svelte.ts b/packages/core/src/get-text-direction/index.svelte.ts
index 8528049..1cebf66 100644
--- a/packages/core/src/get-text-direction/index.svelte.ts
+++ b/packages/core/src/get-text-direction/index.svelte.ts
@@ -7,13 +7,6 @@ import type { CleanupFunction, MaybeElement, MaybeGetter } from '../__internal__
type GetTextDirectionValue = 'auto' | 'ltr' | 'rtl';
interface GetTextDirectionOptions extends ConfigurableDocument {
- /**
- * Whether to auto-cleanup the observer or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
/**
* The element on which to control the text direction.
* @default document.documentElement
@@ -39,7 +32,6 @@ type GetTextDirectionReturn = {
* @note If `observe` is `true`, `current` will be set to `initial`.
*/
removeAttribute: () => void;
- cleanup: CleanupFunction;
};
/**
@@ -52,12 +44,9 @@ export function getTextDirection(options: GetTextDirectionOptions = {}): GetText
element = undefined,
observe = false,
initial = 'ltr',
- autoCleanup = true,
document = defaultDocument
} = options;
- let cleanup: CleanupFunction = noop;
-
const _element = $derived(element ? normalizeValue(element) : document?.documentElement);
let current = $state(getValue());
@@ -69,15 +58,11 @@ export function getTextDirection(options: GetTextDirectionOptions = {}): GetText
});
if (observe && document) {
- cleanup = observeMutation(
+ observeMutation(
() => _element,
() => (current = getValue()),
- { attributes: true, autoCleanup }
- ).cleanup;
- }
-
- if (autoCleanup) {
- onDestroy(() => cleanup());
+ { attributes: true }
+ );
}
function getValue() {
@@ -97,7 +82,6 @@ export function getTextDirection(options: GetTextDirectionOptions = {}): GetText
_element?.setAttribute('dir', current);
},
- removeAttribute,
- cleanup
+ removeAttribute
};
}
diff --git a/packages/core/src/get-text-selection/index.svelte.ts b/packages/core/src/get-text-selection/index.svelte.ts
index acb27d5..e1b39e3 100644
--- a/packages/core/src/get-text-selection/index.svelte.ts
+++ b/packages/core/src/get-text-selection/index.svelte.ts
@@ -1,17 +1,18 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
-import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-import type { AutoCleanup, CleanupFunction } from '../__internal__/types.js';
+import {
+ defaultDocument,
+ defaultWindow,
+ type ConfigurableDocument,
+ type ConfigurableWindow
+} from '../__internal__/configurable.js';
-interface GetTextSelectionOptions extends ConfigurableWindow, AutoCleanup {}
+interface GetTextSelectionOptions extends ConfigurableWindow, ConfigurableDocument {}
type GetTextSelectionReturn = {
readonly text: string;
readonly rects: DOMRect[];
readonly ranges: Range[];
current: Selection | null;
- cleanup: CleanupFunction;
};
/**
@@ -20,24 +21,15 @@ type GetTextSelectionReturn = {
* @see https://svelte-librarian.github.io/sv-use/docs/core/browser/get-text-selection
*/
export function getTextSelection(options: GetTextSelectionOptions = {}): GetTextSelectionReturn {
- const { autoCleanup = true, window = defaultWindow } = options;
-
- let cleanup: CleanupFunction = noop;
+ const { window = defaultWindow, document = defaultDocument } = options;
let current = $state(null);
+
const text = $derived.by(() => current?.toString() ?? '');
const ranges = $derived.by(() => (current ? getRangesFromSelection(current) : []));
const rects = $derived.by(() => ranges.map((range) => range.getBoundingClientRect()));
- if (window) {
- cleanup = handleEventListener(window.document, 'selectionchange', onSelectionChange, {
- passive: true
- });
- }
-
- if (autoCleanup) {
- onDestroy(() => cleanup());
- }
+ handleEventListener(document, 'selectionchange', onSelectionChange, { passive: true });
function getRangesFromSelection(selection: Selection) {
const rangeCount = selection.rangeCount ?? 0;
@@ -67,7 +59,6 @@ export function getTextSelection(options: GetTextSelectionOptions = {}): GetText
},
get ranges() {
return ranges;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/handle-event-listener/index.svelte.test.ts b/packages/core/src/handle-event-listener/index.svelte.test.ts
new file mode 100644
index 0000000..dab51d2
--- /dev/null
+++ b/packages/core/src/handle-event-listener/index.svelte.test.ts
@@ -0,0 +1,315 @@
+import { flushSync } from 'svelte';
+import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
+import { handleEventListener } from './index.svelte.js';
+
+describe('One target, one event, one listener', () => {
+ const event = 'click';
+ const options = { capture: true };
+ const listener = vi.fn();
+
+ let target: HTMLDivElement;
+ let addEventListenerSpy: MockInstance;
+ let removeEventListenerSpy: MockInstance;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ addEventListenerSpy = vi.spyOn(target, 'addEventListener');
+ removeEventListenerSpy = vi.spyOn(target, 'removeEventListener');
+
+ listener.mockReset();
+ });
+
+ it('Adds the listener on the target', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, event, listener, options);
+
+ flushSync();
+
+ expect(addEventListenerSpy).toBeCalledTimes(1);
+ });
+
+ cleanup();
+ });
+
+ it('Triggers the listener when an event is dispatched', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, event, listener, options);
+
+ flushSync();
+
+ expect(listener).not.toHaveBeenCalled();
+
+ target.dispatchEvent(new MouseEvent(event));
+
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ cleanup();
+ });
+
+ it('Removes the listener on unmount', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, event, listener, options);
+
+ flushSync();
+ });
+
+ expect(removeEventListenerSpy).not.toHaveBeenCalled();
+
+ cleanup();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledOnce();
+ });
+});
+
+describe('One target, multiple events, one listener', () => {
+ const events = ['click', 'scroll', 'blur', 'resize'];
+ const options = { capture: true };
+ const listener = vi.fn();
+
+ let target: HTMLDivElement;
+ let addEventListenerSpy: MockInstance;
+ let removeEventListenerSpy: MockInstance;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ addEventListenerSpy = vi.spyOn(target, 'addEventListener');
+ removeEventListenerSpy = vi.spyOn(target, 'removeEventListener');
+
+ listener.mockReset();
+ });
+
+ it('Adds the listener on the target for each event', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, events, listener, options);
+
+ flushSync();
+
+ events.forEach((event) => {
+ expect(addEventListenerSpy).toBeCalledWith(event, listener, options);
+ });
+ });
+
+ cleanup();
+ });
+
+ it('Triggers the listener when an event is dispatched for each event', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, events, listener, options);
+
+ flushSync();
+
+ expect(listener).not.toHaveBeenCalled();
+
+ events.forEach((event, index) => {
+ target.dispatchEvent(new Event(event));
+
+ expect(listener).toBeCalledTimes(index + 1);
+ });
+ });
+
+ cleanup();
+ });
+
+ it('Removes the listener for each event on unmount', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, events, listener, options);
+
+ flushSync();
+ });
+
+ expect(removeEventListenerSpy).not.toBeCalled();
+
+ cleanup();
+
+ expect(removeEventListenerSpy).toBeCalledTimes(events.length);
+ });
+});
+
+describe('One target, one event, multiple listeners', () => {
+ const event = 'click';
+ const listeners = [vi.fn(), vi.fn(), vi.fn()];
+ const options = { capture: true };
+
+ let target: HTMLDivElement;
+ let addEventListenerSpy: MockInstance;
+ let removeEventListenerSpy: MockInstance;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ addEventListenerSpy = vi.spyOn(target, 'addEventListener');
+ removeEventListenerSpy = vi.spyOn(target, 'removeEventListener');
+
+ listeners.forEach((listener) => listener.mockReset());
+ });
+
+ it('Adds each listener on the target', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, event, listeners, options);
+
+ flushSync();
+
+ listeners.forEach((listener) => {
+ expect(addEventListenerSpy).toBeCalledWith(event, listener, options);
+ });
+ });
+
+ cleanup();
+ });
+
+ it('Triggers each listener when an event is dispatched', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, event, listeners, options);
+
+ flushSync();
+
+ listeners.forEach((listener) => expect(listener).not.toHaveBeenCalled());
+
+ target.dispatchEvent(new Event(event));
+
+ listeners.forEach((listener) => expect(listener).toBeCalledTimes(1));
+ });
+
+ cleanup();
+ });
+
+ it('Removes each listener on unmount', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, event, listeners, options);
+
+ flushSync();
+ });
+
+ expect(removeEventListenerSpy).not.toBeCalled();
+
+ cleanup();
+
+ expect(removeEventListenerSpy).toBeCalledTimes(listeners.length);
+ });
+});
+
+describe('One target, multiple events, multiple listeners', () => {
+ const listeners = [vi.fn(), vi.fn(), vi.fn()];
+ const events = ['click', 'scroll', 'blur', 'resize', 'custom-event'];
+ const options = { capture: true };
+
+ let target: HTMLDivElement;
+ let addEventListenerSpy: MockInstance;
+ let removeEventListenerSpy: MockInstance;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ addEventListenerSpy = vi.spyOn(target, 'addEventListener');
+ removeEventListenerSpy = vi.spyOn(target, 'removeEventListener');
+
+ listeners.forEach((listener) => listener.mockReset());
+ });
+
+ it('Adds each listener for each event on the target', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, events, listeners, options);
+
+ flushSync();
+
+ listeners.forEach((listener) => {
+ events.forEach((event) => {
+ expect(addEventListenerSpy).toBeCalledWith(event, listener, options);
+ });
+ });
+ });
+
+ cleanup();
+ });
+
+ it('Triggers each listener for each event that is dispatched', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, events, listeners, options);
+
+ flushSync();
+
+ events.forEach((event, index) => {
+ target.dispatchEvent(new Event(event));
+ listeners.forEach((listener) => {
+ expect(listener).toHaveBeenCalledTimes(index + 1);
+ });
+ });
+ });
+
+ cleanup();
+ });
+
+ it('Removes each listener for each event on unmount', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(target, events, listeners, options);
+
+ flushSync();
+ });
+
+ expect(removeEventListenerSpy).not.toBeCalled();
+
+ cleanup();
+
+ expect(removeEventListenerSpy).toBeCalledTimes(events.length * listeners.length);
+ });
+});
+
+describe('Target(s)', () => {
+ const event = 'click';
+ const listener = vi.fn();
+ const options = { capture: true };
+
+ let target = $state()!;
+ let addEventListenerSpy: MockInstance;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ addEventListenerSpy = vi.spyOn(target, 'addEventListener');
+
+ listener.mockReset();
+ });
+
+ it('Accept an array of targets', async () => {
+ const el1 = document.createElement('div');
+ const el2 = document.createElement('div');
+ const el3 = document.createElement('div');
+
+ const targets = [el1, el2, el3];
+
+ const cleanup = $effect.root(() => {
+ handleEventListener(targets, event, listener, options);
+
+ flushSync();
+
+ targets.forEach((target) => {
+ target.dispatchEvent(new Event(event));
+ });
+
+ expect(listener).toHaveBeenCalledTimes(targets.length);
+ });
+
+ cleanup();
+ });
+
+ it('Attaches the listener again if the target changes', () => {
+ const cleanup = $effect.root(() => {
+ handleEventListener(() => target, event, listener, options);
+
+ flushSync();
+
+ target.dispatchEvent(new Event(event));
+
+ expect(addEventListenerSpy).toHaveBeenCalledOnce();
+
+ target = document.createElement('div');
+ addEventListenerSpy = vi.spyOn(target, 'addEventListener');
+
+ flushSync();
+
+ target.dispatchEvent(new Event(event));
+
+ expect(addEventListenerSpy).toHaveBeenCalledOnce();
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/handle-event-listener/index.svelte.ts b/packages/core/src/handle-event-listener/index.svelte.ts
index e022261..9daeb24 100644
--- a/packages/core/src/handle-event-listener/index.svelte.ts
+++ b/packages/core/src/handle-event-listener/index.svelte.ts
@@ -1,7 +1,5 @@
-import { onDestroy } from 'svelte';
-import { BROWSER } from 'esm-env';
-import { defaultWindow } from '../__internal__/configurable.js';
-import type { Arrayable, CleanupFunction } from '../__internal__/types.js';
+import { normalizeValue, toArray } from '../__internal__/utils.svelte.js';
+import type { Arrayable, MaybeGetter } from '../__internal__/types.js';
interface InferEventTarget {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -11,153 +9,76 @@ interface InferEventTarget {
}
type GeneralEventListener = (evt: EventType) => void;
-type HandleEventListenerOptions = AddEventListenerOptions & {
- /**
- * Whether to auto-cleanup the event listeners or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-};
+type HandleEventListenerOptions = AddEventListenerOptions | boolean;
export function handleEventListener(
+ element: Arrayable>,
event: Arrayable,
listener: Arrayable<(this: Window, ev: WindowEventMap[WindowEvent]) => unknown>,
options?: HandleEventListenerOptions
-): CleanupFunction;
-
-export function handleEventListener(
- element: Window,
- event: Arrayable,
- listener: Arrayable<(this: Window, ev: WindowEventMap[WindowEvent]) => unknown>,
- options?: HandleEventListenerOptions
-): CleanupFunction;
+): void;
export function handleEventListener(
- element: Document,
+ element: Arrayable>,
event: Arrayable,
listener: Arrayable<(this: Window, ev: DocumentEventMap[DocumentEvent]) => unknown>,
options?: HandleEventListenerOptions
-): CleanupFunction;
+): void;
export function handleEventListener<
CustomElement extends HTMLElement,
ElementEvent extends keyof HTMLElementEventMap
>(
- element: CustomElement,
+ element: Arrayable>,
event: Arrayable,
listener: Arrayable<(this: CustomElement, ev: HTMLElementEventMap[ElementEvent]) => unknown>,
options?: HandleEventListenerOptions
-): CleanupFunction;
+): void;
export function handleEventListener(
- element: InferEventTarget,
+ element: Arrayable | null | undefined>>,
event: Arrayable,
listener: Arrayable>,
options?: HandleEventListenerOptions
-): CleanupFunction;
+): void;
export function handleEventListener(
- element: EventTarget,
+ element: Arrayable>,
event: Arrayable,
listener: Arrayable>,
options?: HandleEventListenerOptions
-): CleanupFunction;
+): void;
/**
- * Handles the mounting (and, optionally, unmounting via the {@link HandleEventListenerOptions.autoMountAndCleanup | `autoMountAndCleanup`} option) of an event listener.
- * @returns A cleanup function to manually remove the event listener.
+ * Handles the mounting and unmounting of an event listener.
* @see https://svelte-librarian.github.io/sv-use/docs/core/handle-event-listener
*/
export function handleEventListener<
- ElementOrEvent extends
- | Window
- | Document
- | HTMLElement
- | InferEventTarget
- | Arrayable
- | Arrayable
- | Arrayable
- | Arrayable,
- EventOrListener extends
- | Arrayable
- | Arrayable
- | Arrayable
- | Arrayable
- | Arrayable,
- ListenerOrOptions extends
- | Arrayable
- | Arrayable
- | HandleEventListenerOptions,
- OptionsOrNever extends HandleEventListenerOptions | never
+ Element extends MaybeGetter,
+ Event extends keyof WindowEventMap | keyof DocumentEventMap | keyof HTMLElementEventMap | string
>(
- elementOrEvent: ElementOrEvent,
- eventOrListener: EventOrListener,
- listenerOrOptions?: ListenerOrOptions,
- optionsOrNever?: OptionsOrNever
-): CleanupFunction {
- let element: Window | Document | HTMLElement | undefined,
- events:
- | Array
- | Array
- | Array,
- listeners: Array,
- options: HandleEventListenerOptions,
- autoCleanup: boolean;
-
- if (typeof elementOrEvent === 'string' || Array.isArray(elementOrEvent)) {
- element = defaultWindow;
- events = (Array.isArray(elementOrEvent) ? elementOrEvent : [elementOrEvent]) as
- | Array
- | Array
- | Array;
- listeners = (
- Array.isArray(eventOrListener) ? eventOrListener : [eventOrListener]
- ) as Array;
- const _options = listenerOrOptions as HandleEventListenerOptions | undefined;
- options = {
- signal: _options?.signal ?? undefined,
- capture: _options?.capture ?? undefined,
- once: _options?.once ?? undefined,
- passive: _options?.passive ?? undefined
- };
- autoCleanup = _options?.autoCleanup ?? true;
- } else {
- element = elementOrEvent as Window | Document | HTMLElement;
- events = (Array.isArray(eventOrListener) ? eventOrListener : [eventOrListener]) as
- | Array
- | Array
- | Array;
- listeners = (
- Array.isArray(listenerOrOptions) ? listenerOrOptions : [listenerOrOptions]
- ) as Array;
- options = {
- signal: optionsOrNever?.signal ?? undefined,
- capture: optionsOrNever?.capture ?? undefined,
- once: optionsOrNever?.once ?? undefined,
- passive: optionsOrNever?.passive ?? undefined
- };
- autoCleanup = optionsOrNever?.autoCleanup ?? true;
- }
-
- if (BROWSER && element) {
- events.forEach((evt) => {
- listeners.forEach((listener) => element.addEventListener(evt, listener, options));
- });
- }
-
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
- }
+ elements: Arrayable,
+ events: Arrayable,
+ listeners: Arrayable,
+ options?: HandleEventListenerOptions
+): void {
+ const _elements = $derived(toArray(elements).map(normalizeValue));
+ const _events = $derived(toArray(events));
+ const _listeners = $derived(toArray(listeners));
- function cleanup() {
- events.forEach((evt) => {
- listeners.forEach((listener) => element?.removeEventListener(evt, listener, options));
+ $effect(() => {
+ _elements.forEach((element) => {
+ _events.forEach((event) => {
+ _listeners.forEach((listener) => element?.addEventListener(event, listener, options));
+ });
});
- }
- return cleanup;
+ return () => {
+ _elements.forEach((element) => {
+ _events.forEach((event) => {
+ _listeners.forEach((listener) => element?.removeEventListener(event, listener, options));
+ });
+ });
+ };
+ });
}
diff --git a/packages/core/src/handle-wake-lock/index.svelte.test.ts b/packages/core/src/handle-wake-lock/index.svelte.test.ts
index 975cbc6..c6d76f4 100644
--- a/packages/core/src/handle-wake-lock/index.svelte.test.ts
+++ b/packages/core/src/handle-wake-lock/index.svelte.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import { tick } from 'svelte';
+import { flushSync, tick } from 'svelte';
import { handleWakeLock } from './index.svelte.js';
import { asyncEffectRoot } from '../__internal__/utils.svelte.js';
@@ -30,7 +30,7 @@ class MockDocument extends EventTarget {
describe('Wake Lock API is not supported', () => {
it("doesn't change isActive if it isn't supported", async () => {
const cleanup = asyncEffectRoot(async () => {
- const wakeLock = handleWakeLock({ autoCleanup: false, navigator: {} as Navigator });
+ const wakeLock = handleWakeLock({ navigator: {} as Navigator });
expect(wakeLock.isActive).toBeFalsy();
@@ -41,8 +41,6 @@ describe('Wake Lock API is not supported', () => {
await wakeLock.release();
expect(wakeLock.isActive).toBeFalsy();
-
- wakeLock.cleanup();
});
await cleanup();
@@ -54,7 +52,7 @@ describe('Wake Lock API is supported', () => {
const cleanup = asyncEffectRoot(async () => {
defineWakeLockAPI();
- const wakeLock = handleWakeLock({ autoCleanup: false });
+ const wakeLock = handleWakeLock();
expect(wakeLock.isActive).toBeFalsy();
@@ -65,8 +63,6 @@ describe('Wake Lock API is supported', () => {
await wakeLock.release();
expect(wakeLock.isActive).toBeFalsy();
-
- wakeLock.cleanup();
});
await cleanup();
@@ -77,7 +73,7 @@ describe('Wake Lock API is supported', () => {
vi.useFakeTimers();
defineWakeLockAPI();
- const wakeLock = handleWakeLock({ autoCleanup: false });
+ const wakeLock = handleWakeLock();
expect(wakeLock.isActive).toBeFalsy();
@@ -89,8 +85,6 @@ describe('Wake Lock API is supported', () => {
document.dispatchEvent(new window.Event('visibilitychange'));
expect(wakeLock.isActive).toBeTruthy();
-
- wakeLock.cleanup();
});
await cleanup();
@@ -101,7 +95,7 @@ describe('Wake Lock API is supported', () => {
defineWakeLockAPI();
const mockDocument = new MockDocument();
- const wakeLock = handleWakeLock({ autoCleanup: false, document: mockDocument as Document });
+ const wakeLock = handleWakeLock({ document: mockDocument as Document });
await wakeLock.request('screen');
@@ -110,12 +104,12 @@ describe('Wake Lock API is supported', () => {
mockDocument.visibilityState = 'visible';
mockDocument.dispatchEvent(new Event('visibilitychange'));
+ flushSync();
+
await tick();
await tick();
expect(wakeLock.isActive).toBeTruthy();
-
- wakeLock.cleanup();
});
await cleanup();
@@ -126,7 +120,7 @@ describe('Wake Lock API is supported', () => {
defineWakeLockAPI();
const mockDocument = new MockDocument();
- const wakeLock = handleWakeLock({ autoCleanup: false, document: mockDocument as Document });
+ const wakeLock = handleWakeLock({ document: mockDocument as Document });
await wakeLock.request('screen');
@@ -140,8 +134,6 @@ describe('Wake Lock API is supported', () => {
mockDocument.dispatchEvent(new Event('visibilitychange'));
expect(wakeLock.isActive).toBeFalsy();
-
- wakeLock.cleanup();
});
await cleanup();
@@ -153,7 +145,7 @@ describe('Wake Lock API is supported', () => {
const mockDocument = new MockDocument();
mockDocument.visibilityState = 'visible';
- const wakeLock = handleWakeLock({ autoCleanup: false, document: mockDocument as Document });
+ const wakeLock = handleWakeLock({ document: mockDocument as Document });
await wakeLock.request('screen');
@@ -170,8 +162,6 @@ describe('Wake Lock API is supported', () => {
await wakeLock.request('screen');
expect(wakeLock.isActive).toBeTruthy();
-
- wakeLock.cleanup();
});
await cleanup();
diff --git a/packages/core/src/handle-wake-lock/index.svelte.ts b/packages/core/src/handle-wake-lock/index.svelte.ts
index b41edd2..497c60b 100644
--- a/packages/core/src/handle-wake-lock/index.svelte.ts
+++ b/packages/core/src/handle-wake-lock/index.svelte.ts
@@ -1,15 +1,12 @@
-import { onDestroy } from 'svelte';
import { getDocumentVisibility } from '../get-document-visibility/index.svelte.js';
import { whenever } from '../whenever/index.svelte.js';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
import {
defaultDocument,
defaultNavigator,
type ConfigurableDocument,
type ConfigurableNavigator
} from '../__internal__/configurable.js';
-import type { AutoCleanup, CleanupFunction } from '../__internal__/types.js';
type WakeLockType = 'screen';
@@ -23,7 +20,7 @@ type NavigatorWithWakeLock = Navigator & {
wakeLock: { request: (type: WakeLockType) => Promise };
};
-interface HandleWakeLockOptions extends ConfigurableNavigator, ConfigurableDocument, AutoCleanup {}
+interface HandleWakeLockOptions extends ConfigurableNavigator, ConfigurableDocument {}
type HandleWakeLockReturn = {
readonly isSupported: boolean | undefined;
@@ -32,7 +29,6 @@ type HandleWakeLockReturn = {
request: (type: WakeLockType) => Promise;
forceRequest: (type: WakeLockType) => Promise;
release: () => Promise;
- cleanup: CleanupFunction;
};
/**
@@ -41,25 +37,17 @@ type HandleWakeLockReturn = {
* @see https://svelte-librarian.github.io/sv-use/docs/core/create-vibration
*/
export function handleWakeLock(options: HandleWakeLockOptions = {}): HandleWakeLockReturn {
- const { autoCleanup = true, navigator = defaultNavigator, document = defaultDocument } = options;
-
- let eventListenerCleanup: CleanupFunction = noop;
+ const { navigator = defaultNavigator, document = defaultDocument } = options;
let requestedType = $state(false);
let sentinel = $state(null);
- const documentVisibility = getDocumentVisibility({ autoCleanup, document });
+
+ const documentVisibility = getDocumentVisibility({ document });
const isSupported = $derived.by(() => !!navigator && 'wakeLock' in navigator);
const isActive = $derived.by(() => !!sentinel && documentVisibility.current === 'visible');
if (isSupported) {
- eventListenerCleanup = handleEventListener(
- sentinel!,
- 'release',
- () => {
- requestedType = sentinel?.type ?? false;
- },
- { autoCleanup, passive: true }
- );
+ handleEventListener(sentinel!, 'release', onRelease, { passive: true });
whenever(
() => documentVisibility.current === 'visible' && !!requestedType,
@@ -70,8 +58,8 @@ export function handleWakeLock(options: HandleWakeLockOptions = {}): HandleWakeL
);
}
- if (autoCleanup) {
- onDestroy(() => cleanup());
+ function onRelease() {
+ requestedType = sentinel?.type ?? false;
}
async function forceRequest(type: WakeLockType): Promise {
@@ -96,11 +84,6 @@ export function handleWakeLock(options: HandleWakeLockOptions = {}): HandleWakeL
});
}
- function cleanup() {
- documentVisibility.cleanup();
- eventListenerCleanup();
- }
-
return {
get isSupported() {
return isSupported;
@@ -111,7 +94,6 @@ export function handleWakeLock(options: HandleWakeLockOptions = {}): HandleWakeL
sentinel,
request,
forceRequest,
- release,
- cleanup
+ release
};
}
diff --git a/packages/core/src/has-left-page/index.svelte.test.ts b/packages/core/src/has-left-page/index.svelte.test.ts
new file mode 100644
index 0000000..e9e25af
--- /dev/null
+++ b/packages/core/src/has-left-page/index.svelte.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { hasLeftPage } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+class MockWindow extends EventTarget {
+ dispatchEvent(event: Event): boolean {
+ super.dispatchEvent(event);
+ return true;
+ }
+}
+
+class MockDocument extends EventTarget {
+ dispatchEvent(event: Event): boolean {
+ super.dispatchEvent(event);
+ return true;
+ }
+}
+
+describe('hasLeftPage', () => {
+ let mouseOutEvent: MouseEvent;
+ let mouseEnterEvent: MouseEvent;
+ let mouseLeaveEvent: MouseEvent;
+
+ let mockDocument: MockDocument;
+ let mockWindow: MockWindow;
+
+ beforeEach(() => {
+ mouseOutEvent = Object.defineProperty(new MouseEvent('mouseout'), 'relatedTarget', {
+ configurable: true,
+ writable: true
+ });
+ // @ts-ignore
+ mouseOutEvent.relatedTarget = null;
+
+ mouseEnterEvent = Object.defineProperty(new MouseEvent('mouseenter'), 'relatedTarget', {
+ configurable: true,
+ writable: true
+ });
+ // @ts-ignore
+ mouseEnterEvent.relatedTarget = document.createElement('div');
+
+ mouseLeaveEvent = Object.defineProperty(new MouseEvent('mouseleave'), 'relatedTarget', {
+ configurable: true,
+ writable: true
+ });
+ // @ts-ignore
+ mouseLeaveEvent.relatedTarget = null;
+
+ mockDocument = new MockDocument();
+ mockWindow = new MockWindow();
+ });
+
+ it("Returns 'false' as the initial state", () => {
+ const cleanup = $effect.root(() => {
+ const hasLeft = hasLeftPage();
+
+ flushSync();
+
+ expect(hasLeft.current).toBe(false);
+ });
+
+ cleanup();
+ });
+
+ it("Returns 'true' if the mouse leaves the page", () => {
+ const cleanup = $effect.root(() => {
+ const hasLeft = hasLeftPage();
+
+ flushSync();
+
+ expect(hasLeft.current).toBe(false);
+
+ window.dispatchEvent(mouseOutEvent);
+
+ expect(hasLeft.current).toBe(true);
+ });
+
+ cleanup();
+ });
+
+ it("Returns 'false' if the mouse re-enters the page", () => {
+ const cleanup = $effect.root(() => {
+ const hasLeft = hasLeftPage();
+
+ flushSync();
+
+ expect(hasLeft.current).toBe(false);
+
+ document.dispatchEvent(mouseLeaveEvent);
+
+ expect(hasLeft.current).toBe(true);
+
+ document.dispatchEvent(mouseEnterEvent);
+
+ expect(hasLeft.current).toBe(false);
+ });
+
+ cleanup();
+ });
+
+ it('Accepts a custom window object', () => {
+ const cleanup = $effect.root(() => {
+ const hasLeft = hasLeftPage({ window: mockWindow as Window });
+
+ flushSync();
+
+ expect(hasLeft.current).toBe(false);
+
+ mockWindow.dispatchEvent(mouseOutEvent);
+
+ expect(hasLeft.current).toBe(true);
+ });
+
+ cleanup();
+ });
+
+ it('Accepts a custom document object', () => {
+ const cleanup = $effect.root(() => {
+ const hasLeft = hasLeftPage({ document: mockDocument as Document });
+
+ flushSync();
+
+ expect(hasLeft.current).toBe(false);
+
+ mockDocument.dispatchEvent(mouseLeaveEvent);
+
+ expect(hasLeft.current).toBe(true);
+
+ mockDocument.dispatchEvent(mouseEnterEvent);
+
+ expect(hasLeft.current).toBe(false);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/has-left-page/index.svelte.ts b/packages/core/src/has-left-page/index.svelte.ts
index 0095722..12dac99 100644
--- a/packages/core/src/has-left-page/index.svelte.ts
+++ b/packages/core/src/has-left-page/index.svelte.ts
@@ -1,25 +1,15 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-import type { CleanupFunction } from '../__internal__/types.js';
+import {
+ defaultDocument,
+ defaultWindow,
+ type ConfigurableDocument,
+ type ConfigurableWindow
+} from '../__internal__/configurable.js';
-interface HasLeftPageOptions extends ConfigurableWindow {
- /**
- * Whether to automatically clean up the event listeners or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-}
+interface HasLeftPageOptions extends ConfigurableWindow, ConfigurableDocument {}
type HasLeftPageReturn = {
readonly current: boolean;
- /**
- * Cleans up the event listeners.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
};
/**
@@ -28,12 +18,14 @@ type HasLeftPageReturn = {
* @see https://svelte-librarian.github.io/sv-use/docs/core/has-left-page
*/
export function hasLeftPage(options: HasLeftPageOptions = {}): HasLeftPageReturn {
- const { autoCleanup = true, window = defaultWindow } = options;
+ const { window = defaultWindow, document = defaultDocument } = options;
- const cleanups: CleanupFunction[] = [];
let _current = $state(false);
- const handler = (event: MouseEvent) => {
+ handleEventListener(window, 'mouseout', handler, { passive: true });
+ handleEventListener(document, ['mouseleave', 'mouseenter'], handler, { passive: true });
+
+ function handler(event: MouseEvent) {
if (!window) return;
event = event || (window.event as unknown);
@@ -41,33 +33,11 @@ export function hasLeftPage(options: HasLeftPageOptions = {}): HasLeftPageReturn
const from = event.relatedTarget || event.toElement;
_current = !from;
- };
-
- if (window) {
- cleanups.push(
- handleEventListener(window, 'mouseout', handler, {
- autoCleanup,
- passive: true
- }),
- handleEventListener(document, ['mouseleave', 'mouseenter'], handler, {
- autoCleanup,
- passive: true
- })
- );
- }
-
- if (autoCleanup) {
- onDestroy(() => cleanup());
- }
-
- function cleanup() {
- cleanups.forEach((cleanup) => cleanup());
}
return {
get current() {
return _current;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 5b247e3..c679088 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -12,7 +12,6 @@ export * from './debounce/index.svelte.js';
export * from './debounced-state/index.svelte.js';
export * from './default-state/index.svelte.js';
export * from './get-active-element/index.svelte.js';
-export * from './get-battery/index.svelte.js';
export * from './get-clipboard-text/index.svelte.js';
export * from './get-device-motion/index.svelte.js';
export * from './get-device-orientation/index.svelte.js';
diff --git a/packages/core/src/is-window-focused/index.svelte.test.ts b/packages/core/src/is-window-focused/index.svelte.test.ts
new file mode 100644
index 0000000..b375929
--- /dev/null
+++ b/packages/core/src/is-window-focused/index.svelte.test.ts
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { isWindowFocused } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+describe('isWindowFocused', () => {
+ it('Is initially falsy', () => {
+ const cleanup = $effect.root(() => {
+ const isFocused = isWindowFocused();
+
+ expect(isFocused.current).toBe(false);
+ });
+
+ cleanup();
+ });
+
+ it('Becomes truthy on window focus', () => {
+ const cleanup = $effect.root(() => {
+ const isFocused = isWindowFocused();
+
+ flushSync();
+
+ expect(isFocused.current).toBe(false);
+
+ window.dispatchEvent(new Event('focus'));
+
+ expect(isFocused.current).toBe(true);
+ });
+
+ cleanup();
+ });
+
+ it('Becomes falsy on window blur', () => {
+ const cleanup = $effect.root(() => {
+ const isFocused = isWindowFocused();
+
+ flushSync();
+
+ expect(isFocused.current).toBe(false);
+
+ window.dispatchEvent(new Event('focus'));
+
+ expect(isFocused.current).toBe(true);
+
+ window.dispatchEvent(new Event('blur'));
+
+ expect(isFocused.current).toBe(false);
+ });
+
+ cleanup();
+ });
+});
+
+describe('Custom window', () => {
+ class MockDocument {
+ public hasFocus() {
+ return true;
+ }
+ }
+
+ class MockWindow extends EventTarget {
+ public document = new MockDocument();
+ }
+
+ let mockWindow: MockWindow;
+
+ beforeEach(() => {
+ mockWindow = new MockWindow();
+ });
+
+ it('Is initially truthy', () => {
+ const cleanup = $effect.root(() => {
+ const isFocused = isWindowFocused({ window: mockWindow as Window });
+
+ expect(isFocused.current).toBe(true);
+ });
+
+ cleanup();
+ });
+
+ it('Becomes falsy on window blur', () => {
+ const cleanup = $effect.root(() => {
+ const isFocused = isWindowFocused({ window: mockWindow as Window });
+
+ flushSync();
+
+ expect(isFocused.current).toBe(true);
+
+ mockWindow.dispatchEvent(new Event('blur'));
+
+ expect(isFocused.current).toBe(false);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/is-window-focused/index.svelte.ts b/packages/core/src/is-window-focused/index.svelte.ts
index e2a8406..c27e851 100644
--- a/packages/core/src/is-window-focused/index.svelte.ts
+++ b/packages/core/src/is-window-focused/index.svelte.ts
@@ -1,25 +1,10 @@
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-import type { CleanupFunction } from '../__internal__/types.js';
-import { onDestroy } from 'svelte';
-interface IsWindowFocusedOptions extends ConfigurableWindow {
- /**
- * Whether to automatically cleanup the event listeners or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-}
+type IsWindowFocusedOptions = ConfigurableWindow;
type IsWindowFocusedReturn = {
readonly current: boolean;
- /**
- * Cleans up the event listeners.
- * @note Called automatically if `options.autoCleanup` is `true`.
- */
- cleanup: CleanupFunction;
};
/**
@@ -28,33 +13,16 @@ type IsWindowFocusedReturn = {
* @see https://svelte-librarian.github.io/sv-use/docs/core/is-window-focused
*/
export function isWindowFocused(options: IsWindowFocusedOptions = {}): IsWindowFocusedReturn {
- const { window = defaultWindow, autoCleanup = true } = options;
-
- const cleanups: CleanupFunction[] = [];
+ const { window = defaultWindow } = options;
let _isFocused = $state(!!window && window.document.hasFocus());
- if (window) {
- cleanups.push(
- handleEventListener('blur', () => (_isFocused = false), { passive: true, autoCleanup }),
- handleEventListener('focus', () => (_isFocused = true), { passive: true, autoCleanup })
- );
- }
-
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
- }
-
- function cleanup() {
- cleanups.map((fn) => fn());
- }
+ handleEventListener(window, 'blur', () => (_isFocused = false), { passive: true });
+ handleEventListener(window, 'focus', () => (_isFocused = true), { passive: true });
return {
get current() {
return _isFocused;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/observe-intersection/index.svelte.ts b/packages/core/src/observe-intersection/index.svelte.ts
index b42df81..4aeca04 100644
--- a/packages/core/src/observe-intersection/index.svelte.ts
+++ b/packages/core/src/observe-intersection/index.svelte.ts
@@ -5,18 +5,10 @@ import {
defaultWindow,
type ConfigurableWindow
} from '../__internal__/configurable.js';
-import { noop, normalizeValue, notNullish, toArray } from '../__internal__/utils.svelte.js';
-import type { Arrayable, CleanupFunction, Getter } from '../__internal__/types.js';
-import { onDestroy } from 'svelte';
+import { normalizeValue, notNullish, toArray } from '../__internal__/utils.svelte.js';
+import type { Arrayable, Getter } from '../__internal__/types.js';
export interface ObserveIntersectionOptions extends ConfigurableWindow {
- /**
- * Whether to automatically cleanup the observer or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
/**
* Whether to start the IntersectionObserver on creation or not.
* @default true
@@ -46,41 +38,17 @@ export interface ObserveIntersectionReturn {
readonly isActive: boolean;
/** Resumes the observer. */
resume: () => void;
- /** Pauses the observer. It can also be used to cleanup the observer. */
+ /** Pauses the observer. */
pause: () => void;
- /**
- * Cleans up the observer.
- * @note Alias for `pause`.
- */
- cleanup: () => void;
}
/**
- * Runs a callback when the target is visible on the screen.
- * @param target The target to observe.
+ * Runs a callback when the target(s) are visible on the screen.
+ * @param targets The target(s) to observe.
* @param callback The callback to run when the targets are visible on screen.
* @param options Additional options to customize the behavior.
* @see https://svelte-librarian.github.io/sv-use/docs/core/observe-intersection
*/
-export function observeIntersection(
- target: Getter,
- callback: IntersectionObserverCallback,
- options: ObserveIntersectionOptions
-): ObserveIntersectionReturn;
-
-/**
- * Runs a callback when the targets are visible on the screen.
- * @param targets The targets to observe.
- * @param callback The callback to run when the targets are visible on screen.
- * @param options Additional options to customize the behavior.
- * @see https://svelte-librarian.github.io/sv-use/docs/core/observe-intersection
- */
-export function observeIntersection(
- targets: Array>,
- callback: IntersectionObserverCallback,
- options: ObserveIntersectionOptions
-): ObserveIntersectionReturn;
-
export function observeIntersection(
targets: Arrayable>,
callback: IntersectionObserverCallback,
@@ -90,54 +58,38 @@ export function observeIntersection(
root = defaultDocument,
rootMargin = '0px',
threshold = 0,
- autoCleanup = true,
window = defaultWindow,
immediate = true
} = options;
+ let observer: IntersectionObserver | undefined;
+
const _isSupported = isSupported(() => window !== undefined && 'IntersectionObserver' in window);
const _targets = $derived(toArray(targets).map(normalizeValue).filter(notNullish));
- let cleanup: CleanupFunction = noop;
let _isActive = $state(immediate);
if (_isSupported.current) {
watch(
[() => _targets, () => normalizeValue(root), () => _isActive],
([targets, root]) => {
- cleanup();
-
if (!_isActive) return;
if (!targets.length) return;
- const observer = new IntersectionObserver(callback, {
+ observer = new IntersectionObserver(callback, {
root: root,
rootMargin,
threshold
});
- targets.forEach((el) => el && observer.observe(el));
+ targets.forEach((el) => el && observer!.observe(el));
- cleanup = () => {
- observer.disconnect();
- cleanup = noop;
- };
+ return () => observer?.disconnect();
},
- { runOnMounted: immediate }
+ { immediate }
);
}
- if (autoCleanup) {
- onDestroy(() => {
- pause();
- });
- }
-
- function pause() {
- cleanup();
- _isActive = false;
- }
-
return {
get isSupported() {
return _isSupported.current;
@@ -145,8 +97,9 @@ export function observeIntersection(
get isActive() {
return _isActive;
},
- pause,
- cleanup: pause,
+ pause() {
+ _isActive = false;
+ },
resume() {
_isActive = true;
}
diff --git a/packages/core/src/observe-mutation/index.svelte.ts b/packages/core/src/observe-mutation/index.svelte.ts
index 29c59ae..4139c65 100644
--- a/packages/core/src/observe-mutation/index.svelte.ts
+++ b/packages/core/src/observe-mutation/index.svelte.ts
@@ -2,23 +2,9 @@ import { watch } from '../watch/index.svelte.js';
import { isSupported } from '../__internal__/is.svelte.js';
import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
import { normalizeValue, notNullish, toArray } from '../__internal__/utils.svelte.js';
-import type {
- Arrayable,
- CleanupFunction,
- MaybeElement,
- MaybeGetter
-} from '../__internal__/types.js';
-import { onDestroy } from 'svelte';
+import type { Arrayable, MaybeElement, MaybeGetter } from '../__internal__/types.js';
-interface ObserveMutationOptions extends MutationObserverInit, ConfigurableWindow {
- /**
- * Whether to automatically cleanup the observer or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-}
+interface ObserveMutationOptions extends MutationObserverInit, ConfigurableWindow {}
type ObserveMutationReturn = {
/**
@@ -26,11 +12,6 @@ type ObserveMutationReturn = {
* @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#browser_compatibility
*/
readonly isSupported: boolean;
- /**
- * A function to cleanup the observer.
- * @note Is called automatically if `options.autoCleanup` is set to `true`.
- */
- cleanup: CleanupFunction;
/** Empties the record queue and returns what was in there. */
takeRecords: () => void;
};
@@ -66,50 +47,35 @@ export function observeMutation(
callback: MutationCallback,
options: ObserveMutationOptions = {}
): ObserveMutationReturn {
- const { autoCleanup = true, window = defaultWindow, ...mutationOptions } = options;
+ const { window = defaultWindow, ...mutationOptions } = options;
let _observer: MutationObserver | undefined;
const _isSupported = isSupported(() => window !== undefined && 'MutationObserver' in window);
- const _targets = $derived(
- new Set(toArray(targets).map(normalizeValue).filter(notNullish))
- );
+ const _targets = $derived.by(() => {
+ return new Set(toArray(targets).map(normalizeValue).filter(notNullish));
+ });
watch(
() => _targets,
(targets) => {
- cleanup();
-
if (_isSupported.current && targets.size) {
_observer = new MutationObserver(callback);
targets.forEach((el) => _observer!.observe(el, mutationOptions));
}
- },
- { runOnMounted: true }
- );
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
- }
+ return () => _observer?.disconnect();
+ }
+ );
function takeRecords() {
return _observer?.takeRecords();
}
- function cleanup() {
- if (!_observer) return;
-
- _observer.disconnect();
- _observer = undefined;
- }
-
return {
get isSupported() {
return _isSupported.current;
},
- cleanup,
takeRecords
};
}
diff --git a/packages/core/src/observe-resize/index.svelte.ts b/packages/core/src/observe-resize/index.svelte.ts
index 7ffc4b3..1dad1e1 100644
--- a/packages/core/src/observe-resize/index.svelte.ts
+++ b/packages/core/src/observe-resize/index.svelte.ts
@@ -1,4 +1,3 @@
-import { onDestroy } from 'svelte';
import { normalizeValue, toArray } from '../__internal__/utils.svelte.js';
import { isSupported } from '../__internal__/is.svelte.js';
import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
@@ -23,13 +22,6 @@ type ResizeObserverCallback = (
) => void;
export interface ObserveResizeOptions extends ConfigurableWindow {
- /**
- * Whether to automatically cleanup the observer or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
/**
* Sets which box model the observer will observe changes to. Possible values
* are `content-box` (the default), `border-box` and `device-pixel-content-box`.
@@ -49,8 +41,6 @@ declare class ResizeObserver {
type ObserveResizeReturn = {
/** Whether the {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver | `Resize Observer API`} is supported or not. */
readonly isSupported: boolean;
- /** A function to cleanup the observer. */
- cleanup: CleanupFunction;
};
/**
@@ -84,7 +74,7 @@ export function observeResize(
callback: ResizeObserverCallback,
options: ObserveResizeOptions = {}
): ObserveResizeReturn {
- const { autoCleanup = true, window = defaultWindow, ...observerOptions } = options;
+ const { window = defaultWindow, ...observerOptions } = options;
let _observer: ResizeObserver | undefined;
@@ -92,8 +82,6 @@ export function observeResize(
const _targets = $derived(toArray(targets).map(normalizeValue));
$effect(() => {
- cleanup();
-
if (_isSupported.current && window) {
_observer = new ResizeObserver(callback);
@@ -103,25 +91,13 @@ export function observeResize(
}
}
}
- });
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
- }
-
- function cleanup() {
- if (!_observer) return;
-
- _observer.disconnect();
- _observer = undefined;
- }
+ return () => _observer?.disconnect();
+ });
return {
get isSupported() {
return _isSupported.current;
- },
- cleanup
+ }
};
}
diff --git a/packages/core/src/on-click-outside/index.svelte.ts b/packages/core/src/on-click-outside/index.svelte.ts
index e0bbf2d..0e2e8e4 100644
--- a/packages/core/src/on-click-outside/index.svelte.ts
+++ b/packages/core/src/on-click-outside/index.svelte.ts
@@ -1,17 +1,9 @@
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { toArray } from '../__internal__/utils.svelte.js';
+import { normalizeValue, toArray } from '../__internal__/utils.svelte.js';
import { defaultWindow, type ConfigurableWindow } from '../__internal__/configurable.js';
-import type { Arrayable, CleanupFunction, Getter } from '../__internal__/types.js';
-import { onDestroy } from 'svelte';
+import type { Arrayable, MaybeGetter } from '../__internal__/types.js';
interface OnClickOutsideOptions extends ConfigurableWindow {
- /**
- * Whether to auto-cleanup the event listeners or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default false
- */
- autoCleanup?: boolean;
/**
* Use capturing phase for internal event listener.
* @default true
@@ -21,7 +13,7 @@ interface OnClickOutsideOptions extends ConfigurableWindow {
* Element(s) that will not trigger the event.
* @default []
*/
- ignore?: Arrayable>;
+ ignore?: Arrayable>;
}
/**
@@ -32,57 +24,40 @@ interface OnClickOutsideOptions extends ConfigurableWindow {
* @see https://svelte-librarian.github.io/sv-use/docs/core/on-click-outside
*/
export function onClickOutside(
- element: Getter,
+ element: MaybeGetter,
callback: (event: PointerEvent) => void,
options: OnClickOutsideOptions = {}
-): CleanupFunction {
- const { autoCleanup = false, capture = true, ignore = [], window = defaultWindow } = options;
+): void {
+ const { capture = true, ignore = [], window = defaultWindow } = options;
let shouldListen: boolean = true;
let isProcessingClick: boolean = false;
- const cleanups: CleanupFunction[] = [];
- if (window) {
- cleanups.push(
- handleEventListener(
- window,
- 'click',
- (event: PointerEvent) => {
- if (!isProcessingClick) {
- isProcessingClick = true;
+ const _element = $derived(normalizeValue(element));
+
+ handleEventListener(window, 'click', onClick, { passive: true, capture });
+ handleEventListener(window, 'pointerdown', onPointerDown, { passive: true });
- setTimeout(() => {
- isProcessingClick = false;
- }, 0);
+ function onClick(event: PointerEvent) {
+ if (!isProcessingClick) {
+ isProcessingClick = true;
- handleClick(event);
- }
- },
- { passive: true, capture }
- ),
- handleEventListener(
- window,
- 'pointerdown',
- (e) => {
- const el = element();
- shouldListen = !shouldIgnoreClick(e) && !!(el && !e.composedPath().includes(el));
- },
- { passive: true }
- )
- );
+ setTimeout(() => {
+ isProcessingClick = false;
+ }, 0);
+
+ handleClick(event);
+ }
}
- if (autoCleanup) {
- onDestroy(() => {
- cleanup();
- });
+ function onPointerDown(event: PointerEvent) {
+ shouldListen =
+ !shouldIgnoreClick(event) && !!(_element && !event.composedPath().includes(_element));
}
function handleClick(event: PointerEvent) {
- const el = element();
-
if (!event.target) return;
- if (!el || el === event.target || event.composedPath().includes(el)) return;
+ if (!_element || _element === event.target || event.composedPath().includes(_element)) return;
if (event.detail === 0) {
shouldListen = !shouldIgnoreClick(event);
@@ -98,14 +73,9 @@ export function onClickOutside(
function shouldIgnoreClick(event: PointerEvent) {
return toArray(ignore).some((target) => {
- const el = target();
- return el && (event.target === el || event.composedPath().includes(el));
- });
- }
+ const _target = normalizeValue(target);
- function cleanup() {
- cleanups.forEach((fn) => fn());
+ return _target && (event.target === _target || event.composedPath().includes(_target));
+ });
}
-
- return cleanup;
}
diff --git a/packages/core/src/on-hover/index.svelte.test.ts b/packages/core/src/on-hover/index.svelte.test.ts
new file mode 100644
index 0000000..59b9ea9
--- /dev/null
+++ b/packages/core/src/on-hover/index.svelte.test.ts
@@ -0,0 +1,108 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { onHover } from './index.svelte.js';
+import { flushSync } from 'svelte';
+
+describe('onHover', () => {
+ let div = $state()!;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ beforeEach(() => {
+ div = document.createElement('div');
+ });
+
+ it('Is initially false', () => {
+ const cleanup = $effect.root(() => {
+ const isHovered = onHover(() => div);
+
+ flushSync();
+
+ expect(isHovered.current).toBeFalsy();
+ });
+
+ cleanup();
+ });
+
+ it("Updates on 'mouseenter' and 'mouseleave' events", () => {
+ const cleanup = $effect.root(() => {
+ const isHovered = onHover(() => div);
+
+ flushSync();
+
+ expect(isHovered.current).toBeFalsy();
+
+ div.dispatchEvent(new Event('mouseenter'));
+ expect(isHovered.current).toBeTruthy();
+
+ div.dispatchEvent(new Event('mouseleave'));
+ expect(isHovered.current).toBeFalsy();
+ });
+
+ cleanup();
+ });
+
+ it("Updates on 'mouseover' and 'mouseout' events if options.dirty is true", () => {
+ const cleanup = $effect.root(() => {
+ const isHovered = onHover(() => div, { dirty: true });
+
+ flushSync();
+
+ expect(isHovered.current).toBeFalsy();
+
+ div.dispatchEvent(new Event('mouseover'));
+ expect(isHovered.current).toBeTruthy();
+
+ div.dispatchEvent(new Event('mouseout'));
+ expect(isHovered.current).toBeFalsy();
+ });
+
+ cleanup();
+ });
+
+ it('Updates after a delay if options.delay is set', () => {
+ const cleanup = $effect.root(() => {
+ const delay = 500;
+ const isHovered = onHover(() => div, { delay });
+
+ flushSync();
+
+ expect(isHovered.current).toBeFalsy();
+
+ div.dispatchEvent(new Event('mouseenter'));
+ expect(isHovered.current).toBeFalsy();
+
+ vi.advanceTimersByTime(delay + 100);
+
+ expect(isHovered.current).toBeTruthy();
+ });
+
+ cleanup();
+ });
+
+ it("Triggers the 'onHover' and 'onLeave' callbacks", () => {
+ const cleanup = $effect.root(() => {
+ const onHoverCb = vi.fn();
+ const onLeave = vi.fn();
+
+ const isHovered = onHover(() => div, { onHover: onHoverCb, onLeave });
+
+ flushSync();
+
+ expect(isHovered.current).toBeFalsy();
+
+ div.dispatchEvent(new Event('mouseenter'));
+ expect(onHoverCb).toHaveBeenCalledOnce();
+
+ div.dispatchEvent(new Event('mouseleave'));
+ expect(onLeave).toHaveBeenCalledOnce();
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/on-hover/index.svelte.ts b/packages/core/src/on-hover/index.svelte.ts
index eca1ef5..2dbc3a3 100644
--- a/packages/core/src/on-hover/index.svelte.ts
+++ b/packages/core/src/on-hover/index.svelte.ts
@@ -1,5 +1,5 @@
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop, normalizeValue } from '../__internal__/utils.svelte.js';
+import { noop } from '../__internal__/utils.svelte.js';
import type { MaybeGetter } from '../__internal__/types.js';
type OnHoverOptions = {
@@ -15,6 +15,7 @@ type OnHoverOptions = {
* @default false
*/
dirty?: boolean;
+ onHover?: (event: MouseEvent) => void;
onLeave?(event: MouseEvent): void;
};
@@ -30,76 +31,38 @@ type OnHoverReturn = {
*/
export function onHover(
element: MaybeGetter,
- options?: OnHoverOptions
-): OnHoverReturn;
-
-/**
- * Tracks whether the given element is hovered or not and runs a callback if `true`.
- * @param element The element on which to detect the hover.
- * @param callback The callback to run if the element is hovered.
- * @param options Additional options to customize the behavior.
- * @see https://svelte-librarian.github.io/sv-use/docs/core/on-hover
- */
-export function onHover(
- element: MaybeGetter,
- callback: (event: MouseEvent) => void,
- options?: OnHoverOptions
-): OnHoverReturn;
-
-export function onHover(
- element: MaybeGetter,
- callbackOrOptions?: ((event: MouseEvent) => void) | OnHoverOptions,
- optionsOrNever?: OnHoverOptions
+ options: OnHoverOptions = {}
): OnHoverReturn {
- let callback: (event: MouseEvent) => void = noop;
- let options: OnHoverOptions = { delay: undefined, onLeave: noop, dirty: false };
-
- if (callbackOrOptions && typeof callbackOrOptions === 'function') {
- callback = callbackOrOptions;
- options = {
- ...options,
- ...(optionsOrNever ?? {})
- };
- } else if (callbackOrOptions && typeof callbackOrOptions === 'object') {
- options = {
- ...options,
- ...(callbackOrOptions ?? {})
- };
- }
+ const { delay = undefined, dirty = false, onHover = noop, onLeave = noop } = options;
let timeout: ReturnType;
- let isHovering = $state(false);
- const _element = $derived(normalizeValue(element));
+ let current = $state(false);
- $effect(() => {
- if (_element) {
- handleEventListener(_element, options.dirty ? 'mouseover' : 'mouseenter', (event) => {
- clearTimeout(timeout);
+ handleEventListener(element, dirty ? 'mouseover' : 'mouseenter', (event) => {
+ clearTimeout(timeout);
- if (options.delay) {
- timeout = setTimeout(() => {
- isHovering = true;
- callback(event);
- }, options.delay);
- } else {
- isHovering = true;
- callback(event);
- }
- });
+ if (delay) {
+ timeout = setTimeout(() => {
+ current = true;
+ onHover(event);
+ }, delay);
+ } else {
+ current = true;
+ onHover(event);
+ }
+ });
- handleEventListener(_element, options.dirty ? 'mouseout' : 'mouseleave', (event) => {
- clearTimeout(timeout);
+ handleEventListener(element, dirty ? 'mouseout' : 'mouseleave', (event) => {
+ clearTimeout(timeout);
- isHovering = false;
- options.onLeave?.(event);
- });
- }
+ current = false;
+ onLeave(event);
});
return {
get current() {
- return isHovering;
+ return current;
}
};
}
diff --git a/packages/core/src/on-long-press/index.svelte.test.ts b/packages/core/src/on-long-press/index.svelte.test.ts
index 66b05ef..c461f61 100644
--- a/packages/core/src/on-long-press/index.svelte.test.ts
+++ b/packages/core/src/on-long-press/index.svelte.test.ts
@@ -30,10 +30,7 @@ describe('onLongPress', () => {
const delay = 500;
const callback = vi.fn();
- onLongPress(() => element, callback, {
- autoCleanup: false,
- delay
- });
+ onLongPress(() => element, callback, { delay });
element.dispatchEvent(pointerDownEvent);
expect(callback).not.toHaveBeenCalled();
@@ -47,10 +44,7 @@ describe('onLongPress', () => {
const delay = 500;
const callback = vi.fn();
- onLongPress(() => element, callback, {
- autoCleanup: false,
- delay
- });
+ onLongPress(() => element, callback, { delay });
flushSync();
@@ -69,10 +63,7 @@ describe('onLongPress', () => {
const delay = 1000;
const callback = vi.fn();
- onLongPress(() => element, callback, {
- autoCleanup: false,
- delay
- });
+ onLongPress(() => element, callback, { delay });
flushSync();
@@ -92,7 +83,6 @@ describe('onLongPress', () => {
const callback = vi.fn();
onLongPress(() => element, callback, {
- autoCleanup: false,
delay,
modifiers: {
self: true
@@ -118,10 +108,7 @@ describe('onLongPress', () => {
onLongPress(
() => element,
() => {},
- {
- autoCleanup: false,
- onMouseUp
- }
+ { onMouseUp }
);
flushSync();
@@ -144,11 +131,7 @@ describe('onLongPress', () => {
onLongPress(
() => element,
() => {},
- {
- autoCleanup: false,
- delay,
- onMouseUp
- }
+ { delay, onMouseUp }
);
flushSync();
diff --git a/packages/core/src/on-long-press/index.svelte.ts b/packages/core/src/on-long-press/index.svelte.ts
index 4884c87..d460f95 100644
--- a/packages/core/src/on-long-press/index.svelte.ts
+++ b/packages/core/src/on-long-press/index.svelte.ts
@@ -1,7 +1,6 @@
-import { onDestroy } from 'svelte';
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
import { noop, normalizeValue } from '../__internal__/utils.svelte.js';
-import type { CleanupFunction, MaybeGetter } from '../__internal__/types.js';
+import type { MaybeGetter } from '../__internal__/types.js';
type Position = {
x: number;
@@ -9,13 +8,6 @@ type Position = {
};
type OnLongPressOptions = {
- /**
- * Whether to auto-cleanup the event listeners or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
/**
* Time in milliseconds before the `handler` gets called.
* @default 500
@@ -69,20 +61,23 @@ type OnLongPressModifiers = {
stopPropagation?: boolean;
};
+type OnLongPressReturn = {
+ readonly current: boolean;
+};
+
/**
* Runs a callback when a long press occurs on a given element.
* @param target The element on which to attach the long press.
- * @param handler The callback to execute.
+ * @param callback The callback to execute.
* @param options Additional options to customize the behavior.
- * @returns A cleanup function.
* @see https://svelte-librarian.github.io/sv-use/docs/core/on-long-press
*/
export function onLongPress(
target: MaybeGetter,
- handler: (event: PointerEvent) => void,
+ callback: (event: PointerEvent) => void,
options: OnLongPressOptions = {}
-): CleanupFunction {
- const { autoCleanup = true, delay = 500, distanceThreshold = 10, onMouseUp = noop } = options;
+): OnLongPressReturn {
+ const { delay = 500, distanceThreshold = 10, onMouseUp = noop } = options;
const modifiers: OnLongPressModifiers = {
capture: false,
once: false,
@@ -92,32 +87,21 @@ export function onLongPress(
...(options.modifiers ?? {})
};
- let cleanups: CleanupFunction[] = [];
- const listenerOptions: Parameters['3'] = {
+ const listenerOptions: AddEventListenerOptions = {
capture: modifiers.capture,
- once: modifiers.once,
- autoCleanup
+ once: modifiers.once
};
let timeout: ReturnType | undefined;
let startPosition: Position | undefined;
let startTimestamp: number | undefined;
- let isLongPress = false;
- const _target = $derived(normalizeValue(target));
+ let current = $state(false);
- $effect(() => {
- if (_target) {
- (cleanups = [] as CleanupFunction[]).push(
- handleEventListener(_target!, 'pointerdown', onDown, listenerOptions),
- handleEventListener(_target!, 'pointermove', onMove, listenerOptions),
- handleEventListener(_target!, ['pointerup', 'pointerleave'], onRelease, listenerOptions)
- );
- }
- });
+ const _target = $derived(normalizeValue(target));
- if (autoCleanup) {
- onDestroy(() => cleanup());
- }
+ handleEventListener(target, 'pointerdown', onDown, listenerOptions);
+ handleEventListener(target, 'pointermove', onMove, listenerOptions);
+ handleEventListener(target, ['pointerup', 'pointerleave'], onRelease, listenerOptions);
function onDown(event: PointerEvent) {
if (modifiers.self && event.target !== _target) return;
@@ -134,8 +118,8 @@ export function onLongPress(
startTimestamp = event.timeStamp;
timeout = setTimeout(() => {
- isLongPress = true;
- handler(event);
+ current = true;
+ callback(event);
}, delay);
}
@@ -159,7 +143,7 @@ export function onLongPress(
const [_startTimestamp, _startPosition, _hasLongPressed] = [
startTimestamp,
startPosition,
- isLongPress
+ current
];
reset();
@@ -184,13 +168,12 @@ export function onLongPress(
startPosition = undefined;
startTimestamp = undefined;
- isLongPress = false;
+ current = false;
}
- function cleanup() {
- cleanups.forEach((fn) => fn());
- reset();
- }
-
- return cleanup;
+ return {
+ get current() {
+ return current;
+ }
+ };
}
diff --git a/packages/core/src/on-start-typing/index.svelte.test.ts b/packages/core/src/on-start-typing/index.svelte.test.ts
new file mode 100644
index 0000000..000dd9b
--- /dev/null
+++ b/packages/core/src/on-start-typing/index.svelte.test.ts
@@ -0,0 +1,93 @@
+import { flushSync } from 'svelte';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { onStartTyping } from './index.svelte.js';
+
+describe('onStartTyping', () => {
+ let element = $state()!;
+ let callBackFn: any;
+
+ beforeEach(() => {
+ element = document.createElement('input');
+ element.tabIndex = 1;
+
+ callBackFn = vi.fn();
+ });
+
+ function dispatchKeyDownEvent(keyCode: number) {
+ const ev = new KeyboardEvent('keydown', { keyCode });
+ document.dispatchEvent(ev);
+ }
+
+ function range(size: number, startAt = 0) {
+ return [...Array.from({ length: size }).keys()].map((i) => i + startAt);
+ }
+
+ it('Triggers the callback with any letter', () => {
+ const cleanup = $effect.root(() => {
+ const letters = range(26, 65);
+
+ onStartTyping(callBackFn);
+
+ flushSync();
+
+ letters.forEach((letter) => {
+ document.body.focus();
+ dispatchKeyDownEvent(letter);
+ });
+
+ expect(callBackFn).toBeCalledTimes(letters.length);
+ });
+
+ cleanup();
+ });
+
+ it('Triggers the callback with any number', () => {
+ const cleanup = $effect.root(() => {
+ const numbers = range(10, 48);
+ const numpadNumbers = range(10, 96);
+
+ onStartTyping(callBackFn);
+
+ flushSync();
+
+ numbers.forEach((number) => {
+ document.body.focus();
+ dispatchKeyDownEvent(number);
+ });
+
+ numpadNumbers.forEach((number) => {
+ document.body.focus();
+ dispatchKeyDownEvent(number);
+ });
+
+ expect(callBackFn).toBeCalledTimes(numbers.length + numpadNumbers.length);
+ });
+
+ cleanup();
+ });
+
+ it('Does not trigger the callback with any other character', () => {
+ const cleanup = $effect.root(() => {
+ const arrows = range(4, 37);
+ const functionKeys = range(32, 112);
+
+ onStartTyping(callBackFn);
+
+ flushSync();
+
+ arrows.forEach((arrow) => {
+ document.body.focus();
+ dispatchKeyDownEvent(arrow);
+ });
+
+ functionKeys.forEach((functionKey) => {
+ document.body.focus();
+ dispatchKeyDownEvent(functionKey);
+ });
+
+ expect(callBackFn).toBeCalledTimes(0);
+ });
+
+ cleanup();
+ });
+});
diff --git a/packages/core/src/on-start-typing/index.svelte.ts b/packages/core/src/on-start-typing/index.svelte.ts
index 0c75676..a8c0754 100644
--- a/packages/core/src/on-start-typing/index.svelte.ts
+++ b/packages/core/src/on-start-typing/index.svelte.ts
@@ -1,17 +1,7 @@
import { handleEventListener } from '../handle-event-listener/index.svelte.js';
-import { noop } from '../__internal__/utils.svelte.js';
import { defaultDocument, type ConfigurableDocument } from '../__internal__/configurable.js';
-import type { CleanupFunction } from '../__internal__/types.js';
-interface OnStartTypingOptions extends ConfigurableDocument {
- /**
- * Whether to auto-cleanup the event listener or not.
- *
- * If set to `true`, it must run in the component initialization lifecycle.
- * @default true
- */
- autoCleanup?: boolean;
-}
+type OnStartTypingOptions = ConfigurableDocument;
/**
* Fires when users start typing on non-editable elements.
@@ -22,14 +12,10 @@ interface OnStartTypingOptions extends ConfigurableDocument {
export function onStartTyping(
callback: (event: KeyboardEvent) => void,
options: OnStartTypingOptions = {}
-): CleanupFunction {
- const { autoCleanup = true, document = defaultDocument } = options;
-
- let cleanup: CleanupFunction = noop;
+): void {
+ const { document = defaultDocument } = options;
- if (document) {
- cleanup = handleEventListener(document, 'keydown', onKeydown, { autoCleanup, passive: true });
- }
+ handleEventListener(document, 'keydown', onKeydown, { passive: true });
function onKeydown(event: KeyboardEvent) {
if (!isFocusedElementEditable() && isTypedCharValid(event)) {
@@ -38,37 +24,30 @@ export function onStartTyping(
}
function isFocusedElementEditable() {
- if (!document) return;
-
+ if (!document) return false;
if (!document.activeElement) return false;
if (document.activeElement === document.body) return false;
- // Assume and