This commit is contained in:
eewing
2026-02-17 14:10:16 -06:00
parent 2bca5834c5
commit cf73cd3b4c
11246 changed files with 1690552 additions and 0 deletions
Generated Vendored
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024 Hunter Johnston <https://github.com/huntabyte>
Copyright (c) 2024 Thomas G. Lopes <https://github.com/tglide>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
# Runed
<!-- automd:badges license name="runed" color="green" github="svecosystem/runed" -->
[![npm version](https://flat.badgen.net/npm/v/runed?color=green)](https://npmjs.com/package/runed)
[![npm downloads](https://flat.badgen.net/npm/dm/runed?color=green)](https://npmjs.com/package/runed)
[![license](https://flat.badgen.net/github/license/svecosystem/runed?color=green)](https://github.com/svecosystem/runed/blob/main/LICENSE)
<!-- /automd -->
Runed provides utilities to power your applications using the magic of
[Svelte Runes](https://svelte.dev/blog/runes).
## Installation
```bash
npm install runed
```
Check out the [documentation](https://runed.dev) for more information.
## Sponsors
This project is supported by the following beautiful people/organizations:
<p align="center">
<a href="https://github.com/sponsors/huntabyte">
<img src='https://cdn.jsdelivr.net/gh/huntabyte/static/sponsors.svg' alt="Logos from Sponsors" />
</a>
</p>
## License
<!-- automd:contributors license=MIT author="huntabyte" -->
Published under the [MIT](https://github.com/svecosystem/runed/blob/main/LICENSE) license. Made by
[@TGlide](https://github.com/tglide), [@huntabyte](https://github.com/huntabyte) and
[community](https://github.com/svecosystem/runed/graphs/contributors) 💛 <br><br>
<a href="https://github.com/svecosystem/runed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=svecosystem/runed" /> </a>
<!-- /automd -->
+2
View File
@@ -0,0 +1,2 @@
export * from "./utilities/index.js";
export type { MaybeGetter, Getter, Setter } from "./internal/types.js";
+1
View File
@@ -0,0 +1 @@
export * from "./utilities/index.js";
+23
View File
@@ -0,0 +1,23 @@
export type ConfigurableWindow = {
/** Provide a custom `window` object to use in place of the global `window` object. */
window?: typeof globalThis & Window;
};
export type ConfigurableDocument = {
/** Provide a custom `document` object to use in place of the global `document` object. */
document?: Document;
};
export type ConfigurableDocumentOrShadowRoot = {
document?: DocumentOrShadowRoot;
};
export type ConfigurableNavigator = {
/** Provide a custom `navigator` object to use in place of the global `navigator` object. */
navigator?: Navigator;
};
export type ConfigurableLocation = {
/** Provide a custom `location` object to use in place of the global `location` object. */
location?: Location;
};
export declare const defaultWindow: (Window & typeof globalThis) | undefined;
export declare const defaultDocument: Document | undefined;
export declare const defaultNavigator: Navigator | undefined;
export declare const defaultLocation: Location | undefined;
+5
View File
@@ -0,0 +1,5 @@
import { BROWSER } from "esm-env";
export const defaultWindow = BROWSER && typeof window !== "undefined" ? window : undefined;
export const defaultDocument = BROWSER && typeof window !== "undefined" ? window.document : undefined;
export const defaultNavigator = BROWSER && typeof window !== "undefined" ? window.navigator : undefined;
export const defaultLocation = BROWSER && typeof window !== "undefined" ? window.location : undefined;
+11
View File
@@ -0,0 +1,11 @@
export type Getter<T> = () => T;
export type MaybeGetter<T> = T | Getter<T>;
export type MaybeElementGetter<T extends Element = HTMLElement> = MaybeGetter<T | null | undefined>;
export type MaybeElement = HTMLElement | SVGElement | undefined | null;
export type Setter<T> = (value: T) => void;
export type Expand<T> = T extends infer U ? {
[K in keyof U]: U[K];
} : never;
export type WritableProperties<T> = {
-readonly [P in keyof T]: T[P];
};
+1
View File
@@ -0,0 +1 @@
export {};
+5
View File
@@ -0,0 +1,5 @@
/**
* Get nth item of Array. Negative for backward
*/
export declare function at<T>(array: readonly T[], index: number): T | undefined;
export declare function last<T>(array: readonly T[]): T | undefined;
+14
View File
@@ -0,0 +1,14 @@
/**
* Get nth item of Array. Negative for backward
*/
export function at(array, index) {
const len = array.length;
if (!len)
return undefined;
if (index < 0)
index += len;
return array[index];
}
export function last(array) {
return array[array.length - 1];
}
+1
View File
@@ -0,0 +1 @@
export { BROWSER as browser } from "esm-env";
+1
View File
@@ -0,0 +1 @@
export { BROWSER as browser } from "esm-env";
+25
View File
@@ -0,0 +1,25 @@
/**
* Handles getting the active element in a document or shadow root.
* If the active element is within a shadow root, it will traverse the shadow root
* to find the active element.
* If not, it will return the active element in the document.
*
* @param document A document or shadow root to get the active element from.
* @returns The active element in the document or shadow root.
*/
export declare function getActiveElement(document: DocumentOrShadowRoot): Element | null;
/**
* Returns the owner document of a given element.
*
* @param node The element to get the owner document from.
* @returns
*/
export declare function getOwnerDocument(node: Element | null | undefined, fallback?: Document | undefined): Document | undefined;
/**
* Checks if an element is or is contained by another element.
*
* @param node The element to check if it or its descendants contain the target element.
* @param target The element to check if it is contained by the node.
* @returns
*/
export declare function isOrContainsTarget(node: Element, target: Element): boolean;
+40
View File
@@ -0,0 +1,40 @@
import { defaultDocument } from "../configurable-globals.js";
/**
* Handles getting the active element in a document or shadow root.
* If the active element is within a shadow root, it will traverse the shadow root
* to find the active element.
* If not, it will return the active element in the document.
*
* @param document A document or shadow root to get the active element from.
* @returns The active element in the document or shadow root.
*/
export function getActiveElement(document) {
let activeElement = document.activeElement;
while (activeElement?.shadowRoot) {
const node = activeElement.shadowRoot.activeElement;
if (node === activeElement)
break;
else
activeElement = node;
}
return activeElement;
}
/**
* Returns the owner document of a given element.
*
* @param node The element to get the owner document from.
* @returns
*/
export function getOwnerDocument(node, fallback = defaultDocument) {
return node?.ownerDocument ?? fallback;
}
/**
* Checks if an element is or is contained by another element.
*
* @param node The element to check if it or its descendants contain the target element.
* @param target The element to check if it is contained by the node.
* @returns
*/
export function isOrContainsTarget(node, target) {
return node === target || node.contains(target);
}
+1
View File
@@ -0,0 +1 @@
export declare function noop(): void;
+1
View File
@@ -0,0 +1 @@
export function noop() { }
+2
View File
@@ -0,0 +1,2 @@
import type { MaybeGetter } from "../types.js";
export declare function get<T>(value: MaybeGetter<T>): T;
+7
View File
@@ -0,0 +1,7 @@
import { isFunction } from "./is.js";
export function get(value) {
if (isFunction(value)) {
return value();
}
return value;
}
+3
View File
@@ -0,0 +1,3 @@
export declare function isFunction(value: unknown): value is (...args: unknown[]) => unknown;
export declare function isObject(value: unknown): value is Record<PropertyKey, unknown>;
export declare function isElement(value: unknown): value is Element;
+9
View File
@@ -0,0 +1,9 @@
export function isFunction(value) {
return typeof value === "function";
}
export function isObject(value) {
return value !== null && typeof value === "object";
}
export function isElement(value) {
return value instanceof Element;
}
+1
View File
@@ -0,0 +1 @@
export declare function sleep(ms?: number): Promise<void>;
+3
View File
@@ -0,0 +1,3 @@
export async function sleep(ms = 0) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+35
View File
@@ -0,0 +1,35 @@
# Runed Kit Utilities
This subpath contains utilities that require SvelteKit to be installed.
## Installation
Make sure you have SvelteKit installed:
```bash
npm install @sveltejs/kit
```
## Usage
```typescript
// Import SvelteKit-dependent utilities from the kit subpath
import { useSearchParams } from "runed/kit";
// Regular utilities are still available from the main export
import { isMounted, useEventListener } from "runed";
```
## Available Utilities
- `useSearchParams` - Reactive URL search parameters management (requires SvelteKit)
## Why a separate subpath?
The main `runed` package is designed to work with Svelte alone, without requiring SvelteKit.
However, some utilities like `useSearchParams` need SvelteKit's routing and navigation features. By
providing these utilities through a separate import path, we ensure:
1. The main package remains lightweight and SvelteKit-free
2. SvelteKit users can access additional utilities when needed
3. Clear separation of dependencies and requirements
+1
View File
@@ -0,0 +1 @@
export * from "../utilities/use-search-params/index.js";
+1
View File
@@ -0,0 +1 @@
export * from "../utilities/use-search-params/index.js";
+9
View File
@@ -0,0 +1,9 @@
{
"name": "runed-kit",
"private": true,
"type": "module",
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export declare function testWithEffect(name: string, fn: () => void | Promise<void>): void;
export declare function effectRootScope(fn: () => void | Promise<void>): void | Promise<void>;
export declare function vitestSetTimeoutWrapper(fn: () => void, timeout: number): void;
+22
View File
@@ -0,0 +1,22 @@
import { test, vi } from "vitest";
export function testWithEffect(name, fn) {
test(name, () => effectRootScope(fn));
}
export function effectRootScope(fn) {
let promise;
const cleanup = $effect.root(() => {
promise = fn();
});
if (promise instanceof Promise) {
return promise.finally(cleanup);
}
else {
cleanup();
}
}
export function vitestSetTimeoutWrapper(fn, timeout) {
setTimeout(() => {
fn();
}, timeout);
vi.advanceTimersByTime(timeout);
}
@@ -0,0 +1,18 @@
import { type ConfigurableDocumentOrShadowRoot, type ConfigurableWindow } from "../../internal/configurable-globals.js";
export interface ActiveElementOptions extends ConfigurableDocumentOrShadowRoot, ConfigurableWindow {
}
export declare class ActiveElement {
#private;
constructor(options?: ActiveElementOptions);
get current(): Element | null;
}
/**
* An object holding a reactive value that is equal to `document.activeElement`.
* It automatically listens for changes, keeping the reference up to date.
*
* If you wish to use a custom document or shadowRoot, you should use
* [useActiveElement](https://runed.dev/docs/utilities/active-element) instead.
*
* @see {@link https://runed.dev/docs/utilities/active-element}
*/
export declare const activeElement: ActiveElement;
@@ -0,0 +1,38 @@
import { defaultWindow, } from "../../internal/configurable-globals.js";
import { getActiveElement } from "../../internal/utils/dom.js";
import { on } from "svelte/events";
import { createSubscriber } from "svelte/reactivity";
export class ActiveElement {
#document;
#subscribe;
constructor(options = {}) {
const { window = defaultWindow, document = window?.document } = options;
if (window === undefined)
return;
this.#document = document;
this.#subscribe = createSubscriber((update) => {
const cleanupFocusIn = on(window, "focusin", update);
const cleanupFocusOut = on(window, "focusout", update);
return () => {
cleanupFocusIn();
cleanupFocusOut();
};
});
}
get current() {
this.#subscribe?.();
if (!this.#document)
return null;
return getActiveElement(this.#document);
}
}
/**
* An object holding a reactive value that is equal to `document.activeElement`.
* It automatically listens for changes, keeping the reference up to date.
*
* If you wish to use a custom document or shadowRoot, you should use
* [useActiveElement](https://runed.dev/docs/utilities/active-element) instead.
*
* @see {@link https://runed.dev/docs/utilities/active-element}
*/
export const activeElement = new ActiveElement();
+1
View File
@@ -0,0 +1 @@
export * from "./active-element.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./active-element.svelte.js";
@@ -0,0 +1,40 @@
import type { MaybeGetter } from "../../internal/types.js";
import { type ConfigurableWindow } from "../../internal/configurable-globals.js";
type RafCallbackParams = {
/** The number of milliseconds since the last frame. */
delta: number;
/**
* Time elapsed since the creation of the web page.
* See {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp#the_time_origin Time origin}.
*/
timestamp: DOMHighResTimeStamp;
};
export type AnimationFramesOptions = ConfigurableWindow & {
/**
* Start calling requestAnimationFrame immediately.
*
* @default true
*/
immediate?: boolean;
/**
* Limit the number of frames per second.
* Set to `0` to disable
*
* @default 0
*/
fpsLimit?: MaybeGetter<number>;
};
/**
* Wrapper over {@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame requestAnimationFrame},
* with controls for pausing and resuming the animation, reactive tracking and optional limiting of fps, and utilities.
*/
export declare class AnimationFrames {
#private;
constructor(callback: (params: RafCallbackParams) => void, options?: AnimationFramesOptions);
start(): void;
stop(): void;
toggle(): void;
get fps(): number;
get running(): boolean;
}
export {};
@@ -0,0 +1,72 @@
import { untrack } from "svelte";
import { extract } from "../extract/index.js";
import { defaultWindow } from "../../internal/configurable-globals.js";
/**
* Wrapper over {@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame requestAnimationFrame},
* with controls for pausing and resuming the animation, reactive tracking and optional limiting of fps, and utilities.
*/
export class AnimationFrames {
#callback;
#fpsLimitOption = 0;
#fpsLimit = $derived(extract(this.#fpsLimitOption) ?? 0);
#previousTimestamp = null;
#frame = null;
#fps = $state(0);
#running = $state(false);
#window = defaultWindow;
constructor(callback, options = {}) {
if (options.window)
this.#window = options.window;
this.#fpsLimitOption = options.fpsLimit;
this.#callback = callback;
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.toggle = this.toggle.bind(this);
$effect(() => {
if (options.immediate ?? true) {
untrack(this.start);
}
return this.stop;
});
}
#loop(timestamp) {
if (!this.#running || !this.#window)
return;
if (this.#previousTimestamp === null) {
this.#previousTimestamp = timestamp;
}
const delta = timestamp - this.#previousTimestamp;
const fps = 1000 / delta;
if (this.#fpsLimit && fps > this.#fpsLimit) {
this.#frame = this.#window.requestAnimationFrame(this.#loop.bind(this));
return;
}
this.#fps = fps;
this.#previousTimestamp = timestamp;
this.#callback({ delta, timestamp });
this.#frame = this.#window.requestAnimationFrame(this.#loop.bind(this));
}
start() {
if (!this.#window)
return;
this.#running = true;
this.#previousTimestamp = 0;
this.#frame = this.#window.requestAnimationFrame(this.#loop.bind(this));
}
stop() {
if (!this.#frame || !this.#window)
return;
this.#running = false;
this.#window.cancelAnimationFrame(this.#frame);
this.#frame = null;
}
toggle() {
this.#running ? this.stop() : this.start();
}
get fps() {
return !this.#running ? 0 : this.#fps;
}
get running() {
return this.#running;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./animation-frames.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./animation-frames.svelte.js";
+13
View File
@@ -0,0 +1,13 @@
/**
* Transforms any value into `""` (empty string) or `undefined` for use with HTML attributes where presence indicates truth.
*
* Unlike directly assigning a boolean (e.g., `data-active={true}`), which often renders the string "true" as the attribute's value,
* this utility produces an empty string for truthy inputs (resulting in `<div data-active>`) or `undefined` for falsy inputs
* (resulting in the attribute being omitted entirely: `<div>`). This pattern is standard for native boolean attributes
* and essential for custom ones used as flags.
*
* This is particularly useful for custom `data-*` attributes that drive conditional styling (e.g., CSS selectors like `[data-state="active"]` or `[data-loading]`)
* or JavaScript behavior. It simplifies applying conditional states for CSS frameworks like Tailwind CSS, enabling more terse attribute-based variants
* such as `data-[active]:opacity-100` or `group-data-[loading]:pointer-events-none`, instead of needing to target the attribute's value (`data[active='true']:opacity-100`).
*/
export declare function boolAttr(value: unknown): "" | undefined;
+15
View File
@@ -0,0 +1,15 @@
/**
* Transforms any value into `""` (empty string) or `undefined` for use with HTML attributes where presence indicates truth.
*
* Unlike directly assigning a boolean (e.g., `data-active={true}`), which often renders the string "true" as the attribute's value,
* this utility produces an empty string for truthy inputs (resulting in `<div data-active>`) or `undefined` for falsy inputs
* (resulting in the attribute being omitted entirely: `<div>`). This pattern is standard for native boolean attributes
* and essential for custom ones used as flags.
*
* This is particularly useful for custom `data-*` attributes that drive conditional styling (e.g., CSS selectors like `[data-state="active"]` or `[data-loading]`)
* or JavaScript behavior. It simplifies applying conditional states for CSS frameworks like Tailwind CSS, enabling more terse attribute-based variants
* such as `data-[active]:opacity-100` or `group-data-[loading]:pointer-events-none`, instead of needing to target the attribute's value (`data[active='true']:opacity-100`).
*/
export function boolAttr(value) {
return value ? "" : undefined;
}
+1
View File
@@ -0,0 +1 @@
export { boolAttr } from "./bool-attr.js";
+1
View File
@@ -0,0 +1 @@
export { boolAttr } from "./bool-attr.js";
+42
View File
@@ -0,0 +1,42 @@
export declare class Context<TContext> {
#private;
/**
* @param name The name of the context.
* This is used for generating the context key and error messages.
*/
constructor(name: string);
/**
* The key used to get and set the context.
*
* It is not recommended to use this value directly.
* Instead, use the methods provided by this class.
*/
get key(): symbol;
/**
* Checks whether this has been set in the context of a parent component.
*
* Must be called during component initialisation.
*/
exists(): boolean;
/**
* Retrieves the context that belongs to the closest parent component.
*
* Must be called during component initialisation.
*
* @throws An error if the context does not exist.
*/
get(): TContext;
/**
* Retrieves the context that belongs to the closest parent component,
* or the given fallback value if the context does not exist.
*
* Must be called during component initialisation.
*/
getOr<TFallback>(fallback: TFallback): TContext | TFallback;
/**
* Associates the given value with the current component and returns it.
*
* Must be called during component initialisation.
*/
set(context: TContext): TContext;
}
+65
View File
@@ -0,0 +1,65 @@
import { getContext, hasContext, setContext } from "svelte";
export class Context {
#name;
#key;
/**
* @param name The name of the context.
* This is used for generating the context key and error messages.
*/
constructor(name) {
this.#name = name;
this.#key = Symbol(name);
}
/**
* The key used to get and set the context.
*
* It is not recommended to use this value directly.
* Instead, use the methods provided by this class.
*/
get key() {
return this.#key;
}
/**
* Checks whether this has been set in the context of a parent component.
*
* Must be called during component initialisation.
*/
exists() {
return hasContext(this.#key);
}
/**
* Retrieves the context that belongs to the closest parent component.
*
* Must be called during component initialisation.
*
* @throws An error if the context does not exist.
*/
get() {
const context = getContext(this.#key);
if (context === undefined) {
throw new Error(`Context "${this.#name}" not found`);
}
return context;
}
/**
* Retrieves the context that belongs to the closest parent component,
* or the given fallback value if the context does not exist.
*
* Must be called during component initialisation.
*/
getOr(fallback) {
const context = getContext(this.#key);
if (context === undefined) {
return fallback;
}
return context;
}
/**
* Associates the given value with the current component and returns it.
*
* Must be called during component initialisation.
*/
set(context) {
return setContext(this.#key, context);
}
}
+1
View File
@@ -0,0 +1 @@
export { Context } from "./context.js";
+1
View File
@@ -0,0 +1 @@
export { Context } from "./context.js";
+52
View File
@@ -0,0 +1,52 @@
import type { Getter, MaybeGetter } from "../../internal/types.js";
/**
* A wrapper over {@link useDebounce} that creates a debounced state.
* It takes a "getter" function which returns the state you want to debounce.
* Every time this state changes a timer (re)starts, the length of which is
* configurable with the `wait` arg. When the timer ends the `current` value
* is updated.
*
* @see https://runed.dev/docs/utilities/debounced
*
* @example
*
* <script lang="ts">
* import { Debounced } from "runed";
*
* let search = $state("");
* const debounced = new Debounced(() => search, 500);
* </script>
*
* <div>
* <input bind:value={search} />
* <p>You searched for: <b>{debounced.current}</b></p>
* </div>
*/
export declare class Debounced<T> {
#private;
/**
* @param getter A function that returns the state to watch.
* @param wait The length of time to wait in ms, defaults to 250.
*/
constructor(getter: Getter<T>, wait?: MaybeGetter<number>);
/**
* Get the debounced value.
*/
get current(): T;
/**
* Whether a timer is currently pending.
*/
get pending(): boolean;
/**
* Cancel the latest timer.
*/
cancel(): void;
/**
* Run the debounced function immediately.
*/
updateImmediately(): Promise<void>;
/**
* Set the `current` value without waiting.
*/
setImmediately(v: T): void;
}
+78
View File
@@ -0,0 +1,78 @@
import { useDebounce } from "../use-debounce/use-debounce.svelte.js";
import { watch } from "../watch/watch.svelte.js";
import { noop } from "../../internal/utils/function.js";
/**
* A wrapper over {@link useDebounce} that creates a debounced state.
* It takes a "getter" function which returns the state you want to debounce.
* Every time this state changes a timer (re)starts, the length of which is
* configurable with the `wait` arg. When the timer ends the `current` value
* is updated.
*
* @see https://runed.dev/docs/utilities/debounced
*
* @example
*
* <script lang="ts">
* import { Debounced } from "runed";
*
* let search = $state("");
* const debounced = new Debounced(() => search, 500);
* </script>
*
* <div>
* <input bind:value={search} />
* <p>You searched for: <b>{debounced.current}</b></p>
* </div>
*/
export class Debounced {
#current = $state();
#debounceFn;
/**
* @param getter A function that returns the state to watch.
* @param wait The length of time to wait in ms, defaults to 250.
*/
constructor(getter, wait = 250) {
this.#current = getter(); // immediately set the initial value
this.cancel = this.cancel.bind(this);
this.setImmediately = this.setImmediately.bind(this);
this.updateImmediately = this.updateImmediately.bind(this);
this.#debounceFn = useDebounce(() => {
this.#current = getter();
}, wait);
watch(getter, () => {
this.#debounceFn().catch(noop);
});
}
// isn't the name "current" slightly misleading? it sounds like the latest, raw value
/**
* Get the debounced value.
*/
get current() {
return this.#current;
}
/**
* Whether a timer is currently pending.
*/
get pending() {
return this.#debounceFn.pending;
}
/**
* Cancel the latest timer.
*/
cancel() {
this.#debounceFn.cancel();
}
/**
* Run the debounced function immediately.
*/
updateImmediately() {
return this.#debounceFn.runScheduledNow();
}
/**
* Set the `current` value without waiting.
*/
setImmediately(v) {
this.cancel();
this.#current = v;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./debounced.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./debounced.svelte.js";
@@ -0,0 +1,31 @@
import type { MaybeElementGetter, WritableProperties } from "../../internal/types.js";
import type { ConfigurableWindow } from "../../internal/configurable-globals.js";
type Rect = WritableProperties<Omit<DOMRect, "toJSON">>;
export type ElementRectOptions = ConfigurableWindow & {
initialRect?: DOMRect;
};
/**
* Returns a reactive value holding the size of `node`.
*
* Accepts an `options` object with the following properties:
* - `initialSize`: The initial size of the element. Defaults to `{ width: 0, height: 0 }`.
* - `box`: The box model to use. Can be either `"content-box"` or `"border-box"`. Defaults to `"border-box"`.
*
* @returns an object with `width` and `height` properties.
*
* @see {@link https://runed.dev/docs/utilities/element-size}
*/
export declare class ElementRect {
#private;
constructor(node: MaybeElementGetter, options?: ElementRectOptions);
get x(): number;
get y(): number;
get width(): number;
get height(): number;
get top(): number;
get right(): number;
get bottom(): number;
get left(): number;
get current(): Rect;
}
export {};
+85
View File
@@ -0,0 +1,85 @@
import { extract } from "../extract/extract.svelte.js";
import { useMutationObserver } from "../use-mutation-observer/use-mutation-observer.svelte.js";
import { useResizeObserver } from "../use-resize-observer/use-resize-observer.svelte.js";
/**
* Returns a reactive value holding the size of `node`.
*
* Accepts an `options` object with the following properties:
* - `initialSize`: The initial size of the element. Defaults to `{ width: 0, height: 0 }`.
* - `box`: The box model to use. Can be either `"content-box"` or `"border-box"`. Defaults to `"border-box"`.
*
* @returns an object with `width` and `height` properties.
*
* @see {@link https://runed.dev/docs/utilities/element-size}
*/
export class ElementRect {
#rect = $state({
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
});
constructor(node, options = {}) {
this.#rect = {
width: options.initialRect?.width ?? 0,
height: options.initialRect?.height ?? 0,
x: options.initialRect?.x ?? 0,
y: options.initialRect?.y ?? 0,
top: options.initialRect?.top ?? 0,
right: options.initialRect?.right ?? 0,
bottom: options.initialRect?.bottom ?? 0,
left: options.initialRect?.left ?? 0,
};
const el = $derived(extract(node));
const update = () => {
if (!el)
return;
const rect = el.getBoundingClientRect();
this.#rect.width = rect.width;
this.#rect.height = rect.height;
this.#rect.x = rect.x;
this.#rect.y = rect.y;
this.#rect.top = rect.top;
this.#rect.right = rect.right;
this.#rect.bottom = rect.bottom;
this.#rect.left = rect.left;
};
useResizeObserver(() => el, update, { window: options.window });
$effect(update);
useMutationObserver(() => el, update, {
attributeFilter: ["style", "class"],
window: options.window,
});
}
get x() {
return this.#rect.x;
}
get y() {
return this.#rect.y;
}
get width() {
return this.#rect.width;
}
get height() {
return this.#rect.height;
}
get top() {
return this.#rect.top;
}
get right() {
return this.#rect.right;
}
get bottom() {
return this.#rect.bottom;
}
get left() {
return this.#rect.left;
}
get current() {
return this.#rect;
}
}
+1
View File
@@ -0,0 +1 @@
export { ElementRect } from "./element-rect.svelte.js";
+1
View File
@@ -0,0 +1 @@
export { ElementRect } from "./element-rect.svelte.js";
@@ -0,0 +1,34 @@
import { type ConfigurableWindow } from "../../internal/configurable-globals.js";
import type { MaybeElementGetter } from "../../internal/types.js";
export type ElementSizeOptions = ConfigurableWindow & {
box?: "content-box" | "border-box";
};
/**
* Returns a reactive value holding the size of `node`.
*
* Accepts an `options` object with the following properties:
* - `initialSize`: The initial size of the element. Defaults to `{ width: 0, height: 0 }`.
* - `box`: The box model to use. Can be either `"content-box"` or `"border-box"`. Defaults to `"border-box"`.
*
* @returns an object with `width` and `height` properties.
*
* @see {@link https://runed.dev/docs/utilities/element-size}
*/
export declare class ElementSize {
#private;
constructor(node: MaybeElementGetter, options?: ElementSizeOptions);
calculateSize(): {
width: number;
height: number;
} | undefined;
getSize(): {
width: number;
height: number;
};
get current(): {
width: number;
height: number;
};
get width(): number;
get height(): number;
}
+114
View File
@@ -0,0 +1,114 @@
import { defaultWindow } from "../../internal/configurable-globals.js";
import { get } from "../../internal/utils/get.js";
import { createSubscriber } from "svelte/reactivity";
/**
* Returns a reactive value holding the size of `node`.
*
* Accepts an `options` object with the following properties:
* - `initialSize`: The initial size of the element. Defaults to `{ width: 0, height: 0 }`.
* - `box`: The box model to use. Can be either `"content-box"` or `"border-box"`. Defaults to `"border-box"`.
*
* @returns an object with `width` and `height` properties.
*
* @see {@link https://runed.dev/docs/utilities/element-size}
*/
export class ElementSize {
// no need to use `$state` here since we are using createSubscriber
#size = {
width: 0,
height: 0,
};
#observed = false;
#options;
#node;
#window;
// we use a derived here to extract the width so that if the width doesn't change we don't get a state update
// which we would get if we would just use a getter since the version of the subscriber will be changing
#width = $derived.by(() => {
this.#subscribe?.();
return this.getSize().width;
});
// we use a derived here to extract the height so that if the height doesn't change we don't get a state update
// which we would get if we would just use a getter since the version of the subscriber will be changing
#height = $derived.by(() => {
this.#subscribe?.();
return this.getSize().height;
});
// we need to use a derived here because the class will be created before the node is bound to the ref
#subscribe = $derived.by(() => {
const node$ = get(this.#node);
if (!node$)
return;
return createSubscriber((update) => {
if (!this.#window)
return;
const observer = new this.#window.ResizeObserver((entries) => {
this.#observed = true;
for (const entry of entries) {
const boxSize = this.#options.box === "content-box" ? entry.contentBoxSize : entry.borderBoxSize;
const boxSizeArr = Array.isArray(boxSize) ? boxSize : [boxSize];
this.#size.width = boxSizeArr.reduce((acc, size) => Math.max(acc, size.inlineSize), 0);
this.#size.height = boxSizeArr.reduce((acc, size) => Math.max(acc, size.blockSize), 0);
}
update();
});
observer.observe(node$);
return () => {
this.#observed = false;
observer.disconnect();
};
});
});
constructor(node, options = { box: "border-box" }) {
this.#window = options.window ?? defaultWindow;
this.#options = options;
this.#node = node;
this.#size = {
width: 0,
height: 0,
};
}
calculateSize() {
const element = get(this.#node);
// no element or no window, return undefined, we will return 0x0 in the getSize method
if (!element || !this.#window) {
return;
}
const offsetWidth = element.offsetWidth;
const offsetHeight = element.offsetHeight;
// easy mode, just return offsets
if (this.#options.box === "border-box") {
return {
width: offsetWidth,
height: offsetHeight,
};
}
// hard mode, we need to calculate the content size
const style = this.#window.getComputedStyle(element);
const paddingWidth = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const paddingHeight = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
const borderWidth = parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
const borderHeight = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
const contentWidth = offsetWidth - paddingWidth - borderWidth;
const contentHeight = offsetHeight - paddingHeight - borderHeight;
return {
width: contentWidth,
height: contentHeight,
};
}
getSize() {
// if the resize observer already run we can just return the size
// otherwise we calculate the size if possible or we return the initial size
return this.#observed ? this.#size : (this.calculateSize() ?? this.#size);
}
get current() {
this.#subscribe?.();
return this.getSize();
}
get width() {
return this.#width;
}
get height() {
return this.#height;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./element-size.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./element-size.svelte.js";
+15
View File
@@ -0,0 +1,15 @@
import type { MaybeGetter } from "../../internal/types.js";
/**
* Resolves a value that may be a getter function or a direct value.
*
* If the input is a function, it will be invoked to retrieve the actual value.
* If the resolved value (or the input itself) is `undefined`, the optional
* `defaultValue` is returned instead.
*
* @template T - The expected return type.
* @param value - A value or a function that returns a value.
* @param defaultValue - A fallback value returned if the resolved value is `undefined`.
* @returns The resolved value or the default.
*/
export declare function extract<T>(value: MaybeGetter<T>): T;
export declare function extract<T>(value: MaybeGetter<T | undefined>, defaultValue: T): T;
+13
View File
@@ -0,0 +1,13 @@
import { isFunction } from "../../internal/utils/is.js";
export function extract(value, defaultValue) {
if (isFunction(value)) {
const getter = value;
const gotten = getter();
if (gotten === undefined)
return defaultValue;
return gotten;
}
if (value === undefined)
return defaultValue;
return value;
}
+1
View File
@@ -0,0 +1 @@
export { extract } from "./extract.svelte.js";
+1
View File
@@ -0,0 +1 @@
export { extract } from "./extract.svelte.js";
@@ -0,0 +1,37 @@
export type FSMLifecycleFn<StatesT extends string, EventsT extends string> = (meta: LifecycleFnMeta<StatesT, EventsT>) => void;
export type LifecycleFnMeta<StatesT extends string, EventsT extends string> = {
from: StatesT | null;
to: StatesT;
event: EventsT | null;
args: unknown;
};
export declare function isLifecycleFnMeta<StatesT extends string, EventsT extends string>(meta: unknown): meta is LifecycleFnMeta<StatesT, EventsT>;
export type FSMLifecycle = "_enter" | "_exit";
export type ActionFn<StatesT> = (...args: unknown[]) => StatesT | void;
export type Action<StatesT> = StatesT | ActionFn<StatesT>;
export type StateHandler<StatesT extends string, EventsT extends string> = {
[e in EventsT]?: Action<StatesT>;
} & {
[k in FSMLifecycle]?: FSMLifecycleFn<StatesT, EventsT>;
};
export type Transition<StatesT extends string, EventsT extends string> = {
[s in StatesT]: StateHandler<StatesT, EventsT>;
} & {
"*"?: StateHandler<StatesT, EventsT>;
};
/**
* Defines a strongly-typed finite state machine.
*
* @see {@link https://runed.dev/docs/utilities/finite-state-machine}
*/
export declare class FiniteStateMachine<StatesT extends string, EventsT extends string> {
#private;
readonly states: Transition<StatesT, EventsT>;
constructor(initial: StatesT, states: Transition<StatesT, EventsT>);
/** Triggers a new event and returns the new state. */
send(event: EventsT, ...args: unknown[]): StatesT;
/** Debounces the triggering of an event. */
debounce(wait: number | undefined, event: EventsT, ...args: unknown[]): Promise<StatesT>;
/** The current state. */
get current(): StatesT;
}
@@ -0,0 +1,78 @@
export function isLifecycleFnMeta(meta) {
return (!!meta &&
typeof meta === "object" &&
"to" in meta &&
"from" in meta &&
"event" in meta &&
"args" in meta);
}
/**
* Defines a strongly-typed finite state machine.
*
* @see {@link https://runed.dev/docs/utilities/finite-state-machine}
*/
export class FiniteStateMachine {
#current = $state();
states;
#timeout = {};
constructor(initial, states) {
this.#current = initial;
this.states = states;
this.send = this.send.bind(this);
this.debounce = this.debounce.bind(this);
// synthetically trigger _enter for the initial state.
this.#dispatch("_enter", { from: null, to: initial, event: null, args: [] });
}
#transition(newState, event, args) {
const metadata = { from: this.#current, to: newState, event, args };
this.#dispatch("_exit", metadata);
this.#current = newState;
this.#dispatch("_enter", metadata);
}
#dispatch(event, ...args) {
const action = this.states[this.#current]?.[event] ?? this.states["*"]?.[event];
if (action instanceof Function) {
if (event === "_enter" || event === "_exit") {
if (isLifecycleFnMeta(args[0])) {
action(args[0]);
}
else {
console.warn("Invalid metadata passed to lifecycle function of the FSM.");
}
}
else {
return action(...args);
}
}
else if (typeof action === "string") {
return action;
}
else if (event !== "_enter" && event !== "_exit") {
console.warn("No action defined for event", event, "in state", this.#current);
}
}
/** Triggers a new event and returns the new state. */
send(event, ...args) {
const newState = this.#dispatch(event, ...args);
if (newState && newState !== this.#current) {
this.#transition(newState, event, args);
}
return this.#current;
}
/** Debounces the triggering of an event. */
async debounce(wait = 500, event, ...args) {
if (this.#timeout[event]) {
clearTimeout(this.#timeout[event]);
}
return new Promise((resolve) => {
this.#timeout[event] = setTimeout(() => {
delete this.#timeout[event];
resolve(this.send(event, ...args));
}, wait);
});
}
/** The current state. */
get current() {
return this.#current;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./finite-state-machine.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./finite-state-machine.svelte.js";
+34
View File
@@ -0,0 +1,34 @@
export * from "./active-element/index.js";
export * from "./animation-frames/index.js";
export * from "./context/index.js";
export * from "./debounced/index.js";
export * from "./element-rect/index.js";
export * from "./element-size/index.js";
export * from "./extract/index.js";
export * from "./finite-state-machine/index.js";
export * from "./is-focus-within/index.js";
export * from "./is-idle/index.js";
export * from "./is-in-viewport/index.js";
export * from "./is-mounted/index.js";
export * from "./is-document-visible/index.js";
export * from "./on-click-outside/index.js";
export * from "./persisted-state/index.js";
export * from "./pressed-keys/index.js";
export * from "./previous/index.js";
export * from "./resource/index.js";
export * from "./scroll-state/index.js";
export * from "./state-history/index.js";
export * from "./textarea-autosize/index.js";
export * from "./throttled/index.js";
export * from "./use-debounce/index.js";
export * from "./use-event-listener/index.js";
export * from "./use-geolocation/index.js";
export * from "./use-intersection-observer/index.js";
export * from "./use-mutation-observer/index.js";
export * from "./use-resize-observer/index.js";
export * from "./use-interval/index.js";
export * from "./use-throttle/index.js";
export * from "./watch/index.js";
export * from "./scroll-state/index.js";
export * from "./bool-attr/index.js";
export * from "./on-cleanup/index.js";
+34
View File
@@ -0,0 +1,34 @@
export * from "./active-element/index.js";
export * from "./animation-frames/index.js";
export * from "./context/index.js";
export * from "./debounced/index.js";
export * from "./element-rect/index.js";
export * from "./element-size/index.js";
export * from "./extract/index.js";
export * from "./finite-state-machine/index.js";
export * from "./is-focus-within/index.js";
export * from "./is-idle/index.js";
export * from "./is-in-viewport/index.js";
export * from "./is-mounted/index.js";
export * from "./is-document-visible/index.js";
export * from "./on-click-outside/index.js";
export * from "./persisted-state/index.js";
export * from "./pressed-keys/index.js";
export * from "./previous/index.js";
export * from "./resource/index.js";
export * from "./scroll-state/index.js";
export * from "./state-history/index.js";
export * from "./textarea-autosize/index.js";
export * from "./throttled/index.js";
export * from "./use-debounce/index.js";
export * from "./use-event-listener/index.js";
export * from "./use-geolocation/index.js";
export * from "./use-intersection-observer/index.js";
export * from "./use-mutation-observer/index.js";
export * from "./use-resize-observer/index.js";
export * from "./use-interval/index.js";
export * from "./use-throttle/index.js";
export * from "./watch/index.js";
export * from "./scroll-state/index.js";
export * from "./bool-attr/index.js";
export * from "./on-cleanup/index.js";
+1
View File
@@ -0,0 +1 @@
export * from "./is-document-visible.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./is-document-visible.svelte.js";
@@ -0,0 +1,14 @@
import { type ConfigurableDocument, type ConfigurableWindow } from "../../internal/configurable-globals.js";
export type IsDocumentVisibleOptions = ConfigurableWindow & ConfigurableDocument;
/**
* Tracks whether the current document is visible (i.e., not hidden).
* It listens to the "visibilitychange" event and updates reactively.
*
* @see {@link https://developer.mozilla.org/docs/Web/API/Document/visibilitychange_event}
* @see {@link https://runed.dev/docs/utilities/is-document-visible}
*/
export declare class IsDocumentVisible {
#private;
constructor(options?: IsDocumentVisibleOptions);
get current(): boolean;
}
@@ -0,0 +1,27 @@
import { defaultWindow, } from "../../internal/configurable-globals.js";
import { on } from "svelte/events";
/**
* Tracks whether the current document is visible (i.e., not hidden).
* It listens to the "visibilitychange" event and updates reactively.
*
* @see {@link https://developer.mozilla.org/docs/Web/API/Document/visibilitychange_event}
* @see {@link https://runed.dev/docs/utilities/is-document-visible}
*/
export class IsDocumentVisible {
#visible = $state(false);
constructor(options = {}) {
const window = options.window ?? defaultWindow;
const document = options.document ?? window?.document;
this.#visible = document ? !document.hidden : false;
$effect(() => {
if (!document)
return;
return on(document, "visibilitychange", () => {
this.#visible = !document.hidden;
});
});
}
get current() {
return this.#visible;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./is-focus-within.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./is-focus-within.svelte.js";
@@ -0,0 +1,13 @@
import type { MaybeElementGetter } from "../../internal/types.js";
import { type ActiveElementOptions } from "../active-element/active-element.svelte.js";
export interface IsFocusWithinOptions extends ActiveElementOptions {
}
/**
* Tracks whether the focus is within a target element.
* @see {@link https://runed.dev/docs/utilities/is-focus-within}
*/
export declare class IsFocusWithin {
#private;
constructor(node: MaybeElementGetter, options?: IsFocusWithinOptions);
readonly current: boolean;
}
@@ -0,0 +1,20 @@
import { ActiveElement, } from "../active-element/active-element.svelte.js";
import { extract } from "../extract/extract.svelte.js";
/**
* Tracks whether the focus is within a target element.
* @see {@link https://runed.dev/docs/utilities/is-focus-within}
*/
export class IsFocusWithin {
#node;
#activeElement;
constructor(node, options = {}) {
this.#node = node;
this.#activeElement = new ActiveElement(options);
}
current = $derived.by(() => {
const node = extract(this.#node);
if (node == null)
return false;
return node.contains(this.#activeElement.current);
});
}
+1
View File
@@ -0,0 +1 @@
export * from "./is-idle.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./is-idle.svelte.js";
+38
View File
@@ -0,0 +1,38 @@
import type { MaybeGetter } from "../../internal/types.js";
import { type ConfigurableDocument, type ConfigurableWindow } from "../../internal/configurable-globals.js";
export type IsIdleOptions = ConfigurableDocument & ConfigurableWindow & {
/**
* The events that should set the idle state to `true`
*
* @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel']
*/
events?: MaybeGetter<(keyof WindowEventMap)[]>;
/**
* The timeout in milliseconds before the idle state is set to `true`. Defaults to 60 seconds.
*
* @default 60000
*/
timeout?: MaybeGetter<number>;
/**
* Detect document visibility changes
*
* @default false
*/
detectVisibilityChanges?: MaybeGetter<boolean>;
/**
* The initial state of the idle property
*
* @default false
*/
initialState?: boolean;
};
/**
* Tracks whether the user is being inactive.
* @see {@link https://runed.dev/docs/utilities/is-idle}
*/
export declare class IsIdle {
#private;
constructor(_options?: IsIdleOptions);
get lastActive(): number;
get current(): boolean;
}
+63
View File
@@ -0,0 +1,63 @@
import { extract } from "../extract/index.js";
import { useDebounce } from "../use-debounce/index.js";
import { useEventListener } from "../use-event-listener/use-event-listener.svelte.js";
import { defaultWindow, } from "../../internal/configurable-globals.js";
const DEFAULT_EVENTS = [
"keypress",
"mousemove",
"touchmove",
"click",
"scroll",
];
const DEFAULT_OPTIONS = {
events: DEFAULT_EVENTS,
initialState: false,
timeout: 60000,
};
/**
* Tracks whether the user is being inactive.
* @see {@link https://runed.dev/docs/utilities/is-idle}
*/
export class IsIdle {
#current = $state(false);
#lastActive = $state(Date.now());
constructor(_options) {
const opts = {
...DEFAULT_OPTIONS,
..._options,
};
const window = opts.window ?? defaultWindow;
const document = opts.document ?? window?.document;
const timeout = $derived(extract(opts.timeout));
const events = $derived(extract(opts.events));
const detectVisibilityChanges = $derived(extract(opts.detectVisibilityChanges));
this.#current = opts.initialState;
const debouncedReset = useDebounce(() => {
this.#current = true;
}, () => timeout);
debouncedReset();
const handleActivity = () => {
this.#current = false;
this.#lastActive = Date.now();
debouncedReset();
};
useEventListener(() => window, () => events, () => {
handleActivity();
}, { passive: true });
$effect(() => {
if (!detectVisibilityChanges || !document)
return;
useEventListener(document, ["visibilitychange"], () => {
if (document.hidden)
return;
handleActivity();
});
});
}
get lastActive() {
return this.#lastActive;
}
get current() {
return this.#current;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./is-in-viewport.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./is-in-viewport.svelte.js";
@@ -0,0 +1,14 @@
import type { ConfigurableWindow } from "../../internal/configurable-globals.js";
import type { MaybeElementGetter } from "../../internal/types.js";
import { type UseIntersectionObserverOptions } from "../use-intersection-observer/use-intersection-observer.svelte.js";
export type IsInViewportOptions = ConfigurableWindow & UseIntersectionObserverOptions;
/**
* Tracks if an element is visible within the current viewport.
*
* @see {@link https://runed.dev/docs/utilities/is-in-viewport}
*/
export declare class IsInViewport {
#private;
constructor(node: MaybeElementGetter, options?: IsInViewportOptions);
get current(): boolean;
}
@@ -0,0 +1,25 @@
import { useIntersectionObserver, } from "../use-intersection-observer/use-intersection-observer.svelte.js";
/**
* Tracks if an element is visible within the current viewport.
*
* @see {@link https://runed.dev/docs/utilities/is-in-viewport}
*/
export class IsInViewport {
#isInViewport = $state(false);
constructor(node, options) {
useIntersectionObserver(node, (intersectionObserverEntries) => {
let isIntersecting = this.#isInViewport;
let latestTime = 0;
for (const entry of intersectionObserverEntries) {
if (entry.time >= latestTime) {
latestTime = entry.time;
isIntersecting = entry.isIntersecting;
}
}
this.#isInViewport = isIntersecting;
}, options);
}
get current() {
return this.#isInViewport;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./is-mounted.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./is-mounted.svelte.js";
+11
View File
@@ -0,0 +1,11 @@
/**
* Returns an object with the mounted state of the component
* that invokes this function.
*
* @see {@link https://runed.dev/docs/utilities/is-mounted}
*/
export declare class IsMounted {
#private;
constructor();
get current(): boolean;
}
+21
View File
@@ -0,0 +1,21 @@
import { untrack } from "svelte";
/**
* Returns an object with the mounted state of the component
* that invokes this function.
*
* @see {@link https://runed.dev/docs/utilities/is-mounted}
*/
export class IsMounted {
#isMounted = $state(false);
constructor() {
$effect(() => {
untrack(() => (this.#isMounted = true));
return () => {
this.#isMounted = false;
};
});
}
get current() {
return this.#isMounted;
}
}
+1
View File
@@ -0,0 +1 @@
export { onCleanup } from "./on-cleanup.svelte.js";
+1
View File
@@ -0,0 +1 @@
export { onCleanup } from "./on-cleanup.svelte.js";
+9
View File
@@ -0,0 +1,9 @@
/**
* Register a cleanup function that will be called when the current effect context is disposed,
* which could be when a component is destroyed or when a root effect is disposed.
*
* @see {@link https://runed.dev/docs/utilities/on-cleanup}
*
* @param cb - The cleanup function to register.
*/
export declare function onCleanup(cb: () => void): void;
+15
View File
@@ -0,0 +1,15 @@
/**
* Register a cleanup function that will be called when the current effect context is disposed,
* which could be when a component is destroyed or when a root effect is disposed.
*
* @see {@link https://runed.dev/docs/utilities/on-cleanup}
*
* @param cb - The cleanup function to register.
*/
export function onCleanup(cb) {
$effect(() => {
return () => {
cb();
};
});
}
+1
View File
@@ -0,0 +1 @@
export * from "./on-click-outside.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./on-click-outside.svelte.js";
@@ -0,0 +1,63 @@
import { type ConfigurableDocument, type ConfigurableWindow } from "../../internal/configurable-globals.js";
import type { MaybeElementGetter } from "../../internal/types.js";
export type OnClickOutsideOptions = ConfigurableWindow & ConfigurableDocument & {
/**
* Whether the click outside handler is enabled by default or not.
* If set to false, the handler will not be active until enabled by
* calling the returned `start` function
*
* @default true
*/
immediate?: boolean;
/**
* Controls whether focus events from iframes trigger the callback.
*
* Since iframe click events don't bubble to the parent document,
* you may want to enable this if you need to detect when users
* interact with iframe content.
*
* @default false
*/
detectIframe?: boolean;
};
/**
* A utility that calls a given callback when a click event occurs outside of
* a specified container element.
*
* @template T - The type of the container element, defaults to HTMLElement.
* @param {MaybeElementGetter<T>} container - The container element or a getter function that returns the container element.
* @param {() => void} callback - The callback function to call when a click event occurs outside of the container.
* @param {OnClickOutsideOptions} [opts={}] - Optional configuration object.
* @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document.
* @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not.
* @param {boolean} [opts.detectIframe=false] - Controls whether focus events from iframes trigger the callback.
*
* @example
* ```svelte
* <script>
* import { onClickOutside } from 'runed'
* let container = $state<HTMLElement>()!
*
* const clickOutside = onClickOutside(() => container, () => {
* console.log('clicked outside the container!')
* });
* </script>
*
* <div bind:this={container}>
* <span>Inside</span>
* </div>
* <button>Outside, click me to trigger callback</button>
* <button onclick={clickOutside.start}>Start</button>
* <button onclick={clickOutside.stop}>Stop</button>
* <span>Enabled: {clickOutside.enabled}</span>
*```
* @see {@link https://runed.dev/docs/utilities/on-click-outside}
*/
export declare function onClickOutside<T extends Element = HTMLElement>(container: MaybeElementGetter<T>, callback: (event: PointerEvent | FocusEvent) => void, opts?: OnClickOutsideOptions): {
/** Stop listening for click events outside the container. */
stop: () => boolean;
/** Start listening for click events outside the container. */
start: () => boolean;
/** Whether the click outside handler is currently enabled or not. */
readonly enabled: boolean;
};
@@ -0,0 +1,182 @@
import { defaultWindow, } from "../../internal/configurable-globals.js";
import { getActiveElement, getOwnerDocument, isOrContainsTarget } from "../../internal/utils/dom.js";
import { noop } from "../../internal/utils/function.js";
import { isElement } from "../../internal/utils/is.js";
import { sleep } from "../../internal/utils/sleep.js";
import { on } from "svelte/events";
import { extract } from "../extract/extract.svelte.js";
import { useDebounce } from "../use-debounce/use-debounce.svelte.js";
import { watch } from "../watch/watch.svelte.js";
/**
* A utility that calls a given callback when a click event occurs outside of
* a specified container element.
*
* @template T - The type of the container element, defaults to HTMLElement.
* @param {MaybeElementGetter<T>} container - The container element or a getter function that returns the container element.
* @param {() => void} callback - The callback function to call when a click event occurs outside of the container.
* @param {OnClickOutsideOptions} [opts={}] - Optional configuration object.
* @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document.
* @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not.
* @param {boolean} [opts.detectIframe=false] - Controls whether focus events from iframes trigger the callback.
*
* @example
* ```svelte
* <script>
* import { onClickOutside } from 'runed'
* let container = $state<HTMLElement>()!
*
* const clickOutside = onClickOutside(() => container, () => {
* console.log('clicked outside the container!')
* });
* </script>
*
* <div bind:this={container}>
* <span>Inside</span>
* </div>
* <button>Outside, click me to trigger callback</button>
* <button onclick={clickOutside.start}>Start</button>
* <button onclick={clickOutside.stop}>Stop</button>
* <span>Enabled: {clickOutside.enabled}</span>
*```
* @see {@link https://runed.dev/docs/utilities/on-click-outside}
*/
export function onClickOutside(container, callback, opts = {}) {
const { window = defaultWindow, immediate = true, detectIframe = false } = opts;
const document = opts.document ?? window?.document;
const node = $derived(extract(container));
const nodeOwnerDocument = $derived(getOwnerDocument(node, document));
let enabled = $state(immediate);
let pointerDownIntercepted = false;
let removeClickListener = noop;
let removeListeners = noop;
const handleClickOutside = useDebounce((e) => {
if (!node || !nodeOwnerDocument) {
removeClickListener();
return;
}
if (pointerDownIntercepted === true || !isValidEvent(e, node, nodeOwnerDocument)) {
removeClickListener();
return;
}
if (e.pointerType === "touch") {
/**
* If the pointer type is touch, we add a listener to wait for the click
* event that will follow the pointerdown event if the user interacts in a way
* that would trigger a click event.
*
* This prevents us from prematurely calling the callback if the user is simply
* scrolling or dragging the page.
*/
removeClickListener();
removeClickListener = on(nodeOwnerDocument, "click", () => callback(e), {
once: true,
});
}
else {
/**
* If the pointer type is not touch, we can directly call the callback function
* as the interaction is likely a mouse or pen input which does not require
* additional handling.
*/
callback(e);
}
}, 10);
function addListeners() {
if (!nodeOwnerDocument || !window || !node)
return noop;
const events = [
/**
* CAPTURE INTERACTION START
* Mark the pointerdown event as intercepted to indicate that an interaction
* has started. This helps in distinguishing between valid and invalid events.
*/
on(nodeOwnerDocument, "pointerdown", (e) => {
if (isValidEvent(e, node, nodeOwnerDocument)) {
pointerDownIntercepted = true;
}
}, { capture: true }),
/**
* BUBBLE INTERACTION START
* Mark the pointerdown event as non-intercepted. Debounce `handleClickOutside` to
* avoid prematurely checking if other events were intercepted.
*/
on(nodeOwnerDocument, "pointerdown", (e) => {
pointerDownIntercepted = false;
handleClickOutside(e);
}),
];
if (detectIframe) {
events.push(
/**
* DETECT IFRAME INTERACTIONS
*
* We add a blur event listener to the window to detect when the user
* interacts with an iframe. If the active element is an iframe and it
* is not a descendant of the container, we call the callback function.
*/
on(window, "blur", async (e) => {
await sleep();
const activeElement = getActiveElement(nodeOwnerDocument);
if (activeElement?.tagName === "IFRAME" && !isOrContainsTarget(node, activeElement)) {
callback(e);
}
}));
}
return () => {
for (const event of events) {
event();
}
};
}
function cleanup() {
pointerDownIntercepted = false;
handleClickOutside.cancel();
removeClickListener();
removeListeners();
}
watch([() => enabled, () => node], ([enabled$, node$]) => {
if (enabled$ && node$) {
removeListeners();
removeListeners = addListeners();
}
else {
cleanup();
}
});
$effect(() => {
return () => {
cleanup();
};
});
return {
/** Stop listening for click events outside the container. */
stop: () => (enabled = false),
/** Start listening for click events outside the container. */
start: () => (enabled = true),
/** Whether the click outside handler is currently enabled or not. */
get enabled() {
return enabled;
},
};
}
function isValidEvent(e, container, defaultDocument) {
if ("button" in e && e.button > 0)
return false;
const target = e.target;
if (!isElement(target))
return false;
const ownerDocument = getOwnerDocument(target, defaultDocument);
if (!ownerDocument)
return false;
// handle the case where a user may have pressed a pseudo element by
// checking the bounding rect of the container
if (target === container) {
const rect = container.getBoundingClientRect();
const wasInsideClick = rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
return !wasInsideClick;
}
return ownerDocument.documentElement.contains(target) && !isOrContainsTarget(container, target);
}
+1
View File
@@ -0,0 +1 @@
export * from "./persisted-state.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./persisted-state.svelte.js";
@@ -0,0 +1,29 @@
import { type ConfigurableWindow } from "../../internal/configurable-globals.js";
type Serializer<T> = {
serialize: (value: T) => string;
deserialize: (value: string) => T | undefined;
};
type StorageType = "local" | "session";
type PersistedStateOptions<T> = {
/** The storage type to use. Defaults to `local`. */
storage?: StorageType;
/** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */
serializer?: Serializer<T>;
/** Whether to sync with the state changes from other tabs. Defaults to `true`. */
syncTabs?: boolean;
} & ConfigurableWindow;
/**
* Creates reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage.
* @param key The unique key used to store the state in the storage.
* @param initialValue The initial value of the state if not already present in the storage.
* @param options Configuration options including storage type, serializer for complex data types, and whether to sync state changes across tabs.
*
* @see {@link https://runed.dev/docs/utilities/persisted-state}
*/
export declare class PersistedState<T> {
#private;
constructor(key: string, initialValue: T, options?: PersistedStateOptions<T>);
get current(): T;
set current(newValue: T);
}
export {};
@@ -0,0 +1,117 @@
import { defaultWindow } from "../../internal/configurable-globals.js";
import { on } from "svelte/events";
import { createSubscriber } from "svelte/reactivity";
function getStorage(storageType, window) {
switch (storageType) {
case "local":
return window.localStorage;
case "session":
return window.sessionStorage;
}
}
function proxy(value, root, proxies, subscribe, update, serialize) {
if (value === null || typeof value !== "object") {
return value;
}
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype && !Array.isArray(value)) {
return value;
}
let p = proxies.get(value);
if (!p) {
p = new Proxy(value, {
get: (target, property) => {
subscribe?.();
return proxy(Reflect.get(target, property), root, proxies, subscribe, update, serialize);
},
set: (target, property, value) => {
update?.();
Reflect.set(target, property, value);
serialize(root);
return true;
},
});
proxies.set(value, p);
}
return p;
}
/**
* Creates reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage.
* @param key The unique key used to store the state in the storage.
* @param initialValue The initial value of the state if not already present in the storage.
* @param options Configuration options including storage type, serializer for complex data types, and whether to sync state changes across tabs.
*
* @see {@link https://runed.dev/docs/utilities/persisted-state}
*/
export class PersistedState {
#current;
#key;
#serializer;
#storage;
#subscribe;
#update;
#proxies = new WeakMap();
constructor(key, initialValue, options = {}) {
const { storage: storageType = "local", serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, syncTabs = true, } = options;
const window = "window" in options ? options.window : defaultWindow; // window is not mockable to be undefined without this, because JavaScript will fill undefined with `= default`
this.#current = initialValue;
this.#key = key;
this.#serializer = serializer;
if (window === undefined)
return;
const storage = getStorage(storageType, window);
this.#storage = storage;
const existingValue = storage.getItem(key);
if (existingValue !== null) {
this.#current = this.#deserialize(existingValue);
}
else {
this.#serialize(initialValue);
}
this.#subscribe = createSubscriber((update) => {
this.#update = update;
const cleanup = syncTabs && storageType === "local"
? on(window, "storage", this.#handleStorageEvent)
: null;
return () => {
cleanup?.();
this.#update = undefined;
};
});
}
get current() {
this.#subscribe?.();
const storageItem = this.#storage?.getItem(this.#key);
const root = storageItem ? this.#deserialize(storageItem) : this.#current;
return proxy(root, root, this.#proxies, this.#subscribe?.bind(this), this.#update?.bind(this), this.#serialize.bind(this));
}
set current(newValue) {
this.#serialize(newValue);
this.#update?.();
}
#handleStorageEvent = (event) => {
if (event.key !== this.#key || event.newValue === null)
return;
this.#current = this.#deserialize(event.newValue);
this.#update?.();
};
#deserialize(value) {
try {
return this.#serializer.deserialize(value);
}
catch (error) {
console.error(`Error when parsing "${value}" from persisted store "${this.#key}"`, error);
return;
}
}
#serialize(value) {
try {
if (value != undefined) {
this.#storage?.setItem(this.#key, this.#serializer.serialize(value));
}
}
catch (error) {
console.error(`Error when writing value from persisted store "${this.#key}" to ${this.#storage}`, error);
}
}
}
+1
View File
@@ -0,0 +1 @@
export { PressedKeys } from "./pressed-keys.svelte.js";
+1
View File
@@ -0,0 +1 @@
export { PressedKeys } from "./pressed-keys.svelte.js";

Some files were not shown because too many files have changed in this diff Show More