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
+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";
@@ -0,0 +1,20 @@
import { type ConfigurableWindow } from "../../internal/configurable-globals.js";
export type PressedKeysOptions = ConfigurableWindow;
/**
* Tracks which keys are currently pressed.
*
* @see {@link https://runed.dev/docs/utilities/pressed-keys}
*/
export declare class PressedKeys {
#private;
constructor(options?: PressedKeysOptions);
has(...keys: string[]): boolean;
get all(): string[];
/**
* Registers a callback to execute when specified key combination is pressed.
*
* @param keys - Array or single string of keys to monitor
* @param callback - Function to execute when the key combination is matched
*/
onKeys(keys: string | string[], callback: () => void): void;
}
+88
View File
@@ -0,0 +1,88 @@
import { on } from "svelte/events";
import { createSubscriber } from "svelte/reactivity";
import { watch } from "../watch/index.js";
import { defaultWindow } from "../../internal/configurable-globals.js";
const modifierKeys = ["meta", "control", "alt", "shift"];
/**
* Tracks which keys are currently pressed.
*
* @see {@link https://runed.dev/docs/utilities/pressed-keys}
*/
export class PressedKeys {
#pressedKeys = $state([]);
#subscribe;
constructor(options = {}) {
const { window = defaultWindow } = options;
this.has = this.has.bind(this);
if (!window)
return;
this.#subscribe = createSubscriber((update) => {
const keydown = on(window, "keydown", (e) => {
const key = e.key.toLowerCase();
if (!this.#pressedKeys.includes(key)) {
this.#pressedKeys.push(key);
}
update();
});
const keyup = on(window, "keyup", (e) => {
const key = e.key.toLowerCase();
// Special handling for modifier keys (meta, control, alt, shift)
// This addresses issues with OS/browser intercepting certain key combinations
// where non-modifier keyup events might not fire properly
if (modifierKeys.includes(key)) {
// When a modifier key is released, clear all non-modifier keys
// but keep other modifier keys that might still be pressed
// This prevents keys from getting "stuck" in the pressed state
this.#pressedKeys = this.#pressedKeys.filter((k) => modifierKeys.includes(k));
}
// Regular key removal
this.#pressedKeys = this.#pressedKeys.filter((k) => k !== key);
update();
});
// Handle window blur events (switching applications, clicking outside browser)
// Reset all keys when user shifts focus away from the window
const blur = on(window, "blur", () => {
this.#pressedKeys = [];
update();
});
// Handle tab visibility changes (switching browser tabs)
// This catches cases where the window doesn't lose focus but the tab is hidden
const visibilityChange = on(document, "visibilitychange", () => {
if (document.visibilityState === "hidden") {
this.#pressedKeys = [];
update();
}
});
return () => {
keydown();
keyup();
blur();
visibilityChange();
};
});
}
has(...keys) {
this.#subscribe?.();
const normalizedKeys = keys.map((key) => key.toLowerCase());
return normalizedKeys.every((key) => this.#pressedKeys.includes(key));
}
get all() {
this.#subscribe?.();
return this.#pressedKeys;
}
/**
* Registers a callback to execute when specified key combination is pressed.
*
* @param keys - Array or single string of keys to monitor
* @param callback - Function to execute when the key combination is matched
*/
onKeys(keys, callback) {
this.#subscribe?.();
const keysToMonitor = Array.isArray(keys) ? keys : [keys];
watch(() => this.all, () => {
if (this.has(...keysToMonitor)) {
callback();
}
});
}
}

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