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
@@ -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();
}
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./previous.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./previous.svelte.js";
+11
View File
@@ -0,0 +1,11 @@
import type { Getter } from "../../internal/types.js";
/**
* Holds the previous value of a getter.
*
* @see {@link https://runed.dev/docs/utilities/previous}
*/
export declare class Previous<T> {
#private;
constructor(getter: Getter<T>, initialValue?: T);
get current(): T | undefined;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Holds the previous value of a getter.
*
* @see {@link https://runed.dev/docs/utilities/previous}
*/
export class Previous {
#previousCallback = () => undefined;
#previous = $derived.by(() => this.#previousCallback());
constructor(getter, initialValue) {
let actualPrevious = undefined;
if (initialValue !== undefined)
actualPrevious = initialValue;
this.#previousCallback = () => {
try {
return actualPrevious;
}
finally {
actualPrevious = getter();
}
};
}
get current() {
return this.#previous;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./resource.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./resource.svelte.js";
+14
View File
@@ -0,0 +1,14 @@
export type ResponseData = {
id: number;
name: string;
email: string;
};
export type SearchResponseData = {
results: {
id: number;
title: string;
}[];
page: number;
total: number;
};
export declare const handlers: import("msw").HttpHandler[];
+30
View File
@@ -0,0 +1,30 @@
import { http, delay, HttpResponse } from "msw";
export const handlers = [
// Basic user endpoint
http.get("https://api.example.com/users/:id", async ({ params }) => {
await delay(50);
return HttpResponse.json({
id: Number(params.id),
name: `User ${params.id}`,
email: `user${params.id}@example.com`,
});
}),
// Search endpoint with query params
http.get("https://api.example.com/search", ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const page = Number(url.searchParams.get("page")) || 1;
return HttpResponse.json({
results: [
{ id: page * 1, title: `Result 1 for ${query}` },
{ id: page * 2, title: `Result 2 for ${query}` },
],
page,
total: 10,
});
}),
// Endpoint that can fail
http.get("https://api.example.com/error-prone", () => {
return new HttpResponse(null, { status: 500 });
}),
];
+147
View File
@@ -0,0 +1,147 @@
import type { Getter } from "../../internal/types.js";
/**
* Configuration options for the resource function
*/
export type ResourceOptions<Data> = {
/** Skip initial fetch when true */
lazy?: boolean;
/** Only fetch once when true */
once?: boolean;
/** Initial value for the resource */
initialValue?: Data;
/** Debounce time in milliseconds */
debounce?: number;
/** Throttle time in milliseconds */
throttle?: number;
};
/**
* Core state of a resource
*/
export type ResourceState<Data, HasInitialValue extends boolean = false> = {
/** Current value of the resource */
current: HasInitialValue extends true ? Data : Data | undefined;
/** Whether the resource is currently loading */
loading: boolean;
/** Error if the fetch failed */
error: Error | undefined;
};
/**
* Return type of the resource function, extends ResourceState with additional methods
*/
export type ResourceReturn<Data, RefetchInfo = unknown, HasInitialValue extends boolean = false> = ResourceState<Data, HasInitialValue> & {
/** Update the resource value directly */
mutate: (value: Data) => void;
/** Re-run the fetcher with current values */
refetch: (info?: RefetchInfo) => Promise<Data | undefined>;
};
export type ResourceFetcherRefetchInfo<Data, RefetchInfo = unknown> = {
/** Previous data return from fetcher */
data: Data | undefined;
/** Whether the fetcher is currently refetching or it can be the value you passed to refetch. */
refetching: RefetchInfo | boolean;
/** A cleanup function that will be called when the source is invalidated and the fetcher is about to re-run */
onCleanup: (fn: () => void) => void;
/** AbortSignal for cancelling fetch requests */
signal: AbortSignal;
};
export type ResourceFetcher<Source, Data, RefetchInfo = unknown> = (
/** Current value of the source */
value: Source extends Array<unknown> ? {
[K in keyof Source]: Source[K];
} : Source,
/** Previous value of the source */
previousValue: Source extends Array<unknown> ? {
[K in keyof Source]: Source[K];
} : Source | undefined, info: ResourceFetcherRefetchInfo<Data, RefetchInfo>) => Promise<Data>;
/**
* Creates a reactive resource that combines reactive dependency tracking with async data fetching.
* This version uses watch under the hood and runs after render.
* For pre-render execution, use resource.pre().
*
* Features:
* - Automatic request cancellation when dependencies change
* - Built-in loading and error states
* - Support for initial values and lazy loading
* - Type-safe reactive dependencies
*
* @example
* ```typescript
* // Basic usage with automatic request cancellation
* const userResource = resource(
* () => userId,
* async (newId, prevId, { signal }) => {
* const res = await fetch(`/api/users/${newId}`, { signal });
* return res.json();
* }
* );
*
* // Multiple dependencies
* const searchResource = resource(
* [() => query, () => page],
* async ([query, page], [prevQuery, prevPage], { signal }) => {
* const res = await fetch(
* `/api/search?q=${query}&page=${page}`,
* { signal }
* );
* return res.json();
* },
* { lazy: true }
* );
*
* // Custom cleanup with built-in request cancellation
* const streamResource = resource(
* () => streamId,
* async (id, prevId, { signal, onCleanup }) => {
* const eventSource = new EventSource(`/api/stream/${id}`);
* onCleanup(() => eventSource.close());
*
* const res = await fetch(`/api/stream/${id}/init`, { signal });
* return res.json();
* }
* );
* ```
*/
export declare function resource<Source, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Source, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Source, any, RefetchInfo>>(source: Getter<Source>, fetcher: Fetcher, options: ResourceOptions<Awaited<ReturnType<Fetcher>>> & {
initialValue: Awaited<ReturnType<Fetcher>>;
}): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, true>;
export declare function resource<Source, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Source, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Source, any, RefetchInfo>>(source: Getter<Source>, fetcher: Fetcher, options?: Omit<ResourceOptions<Awaited<ReturnType<Fetcher>>>, "initialValue">): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, false>;
export declare function resource<Sources extends Array<unknown>, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Sources, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Sources, any, RefetchInfo>>(sources: {
[K in keyof Sources]: Getter<Sources[K]>;
}, fetcher: Fetcher, options: ResourceOptions<Awaited<ReturnType<Fetcher>>> & {
initialValue: Awaited<ReturnType<Fetcher>>;
}): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, true>;
export declare function resource<Sources extends Array<unknown>, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Sources, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Sources, any, RefetchInfo>>(sources: {
[K in keyof Sources]: Getter<Sources[K]>;
}, fetcher: Fetcher, options?: Omit<ResourceOptions<Awaited<ReturnType<Fetcher>>>, "initialValue">): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, false>;
export declare namespace resource {
var pre: typeof resourcePre;
}
/**
* Helper function to create a resource with pre-effect (runs before render).
* Uses watch.pre internally instead of watch for pre-render execution.
* Includes all features of the standard resource including automatic request cancellation.
*
* @example
* ```typescript
* // Pre-render resource with automatic cancellation
* const data = resource.pre(
* () => query,
* async (newQuery, prevQuery, { signal }) => {
* const res = await fetch(`/api/search?q=${newQuery}`, { signal });
* return res.json();
* }
* );
* ```
*/
export declare function resourcePre<Source, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Source, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Source, any, RefetchInfo>>(source: Getter<Source>, fetcher: Fetcher, options: ResourceOptions<Awaited<ReturnType<Fetcher>>> & {
initialValue: Awaited<ReturnType<Fetcher>>;
}): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, true>;
export declare function resourcePre<Source, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Source, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Source, any, RefetchInfo>>(source: Getter<Source>, fetcher: Fetcher, options?: Omit<ResourceOptions<Awaited<ReturnType<Fetcher>>>, "initialValue">): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, false>;
export declare function resourcePre<Sources extends Array<unknown>, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Sources, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Sources, any, RefetchInfo>>(sources: {
[K in keyof Sources]: Getter<Sources[K]>;
}, fetcher: Fetcher, options: ResourceOptions<Awaited<ReturnType<Fetcher>>> & {
initialValue: Awaited<ReturnType<Fetcher>>;
}): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, true>;
export declare function resourcePre<Sources extends Array<unknown>, RefetchInfo = unknown, Fetcher extends ResourceFetcher<Sources, Awaited<ReturnType<Fetcher>>, RefetchInfo> = ResourceFetcher<Sources, any, RefetchInfo>>(sources: {
[K in keyof Sources]: Getter<Sources[K]>;
}, fetcher: Fetcher, options?: Omit<ResourceOptions<Awaited<ReturnType<Fetcher>>>, "initialValue">): ResourceReturn<Awaited<ReturnType<Fetcher>>, RefetchInfo, false>;
+139
View File
@@ -0,0 +1,139 @@
import { watch } from "../watch/index.js";
// Helper functions for debounce and throttle
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce(fn, delay) {
let timeoutId;
let lastResolve = null;
return (...args) => {
return new Promise((resolve) => {
if (lastResolve) {
lastResolve(undefined);
}
lastResolve = resolve;
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const result = await fn(...args);
if (lastResolve) {
lastResolve(result);
lastResolve = null;
}
}, delay);
});
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle(fn, delay) {
let lastRun = 0;
let lastPromise = null;
return (...args) => {
const now = Date.now();
if (lastRun && now - lastRun < delay) {
return lastPromise ?? Promise.resolve(undefined);
}
lastRun = now;
lastPromise = fn(...args);
return lastPromise;
};
}
function runResource(source, fetcher, options = {}, effectFn) {
const { lazy = false, once = false, initialValue, debounce: debounceTime, throttle: throttleTime, } = options;
// Create state
let current = $state(initialValue);
let loading = $state(false);
let error = $state(undefined);
let cleanupFns = $state([]);
// Helper function to run cleanup functions
const runCleanup = () => {
cleanupFns.forEach((fn) => fn());
cleanupFns = [];
};
// Helper function to register cleanup
const onCleanup = (fn) => {
cleanupFns = [...cleanupFns, fn];
};
// Create the base fetcher function
const baseFetcher = async (value, previousValue, refetching = false) => {
try {
loading = true;
error = undefined;
runCleanup();
// Create new AbortController for this fetch
const controller = new AbortController();
onCleanup(() => controller.abort());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await fetcher(value, previousValue, {
data: current,
refetching,
onCleanup,
signal: controller.signal,
});
current = result;
return result;
}
catch (e) {
if (!(e instanceof DOMException && e.name === "AbortError")) {
error = e;
}
return undefined;
}
finally {
loading = false;
}
};
// Apply debounce or throttle if specified
const runFetcher = debounceTime
? debounce(baseFetcher, debounceTime)
: throttleTime
? throttle(baseFetcher, throttleTime)
: baseFetcher;
// Setup effect
const sources = Array.isArray(source) ? source : [source];
let prevValues;
effectFn((values, previousValues) => {
// Skip if once and already ran
if (once && prevValues) {
return;
}
prevValues = values;
runFetcher(Array.isArray(source) ? values : values[0], Array.isArray(source) ? previousValues : previousValues?.[0]);
}, { lazy });
return {
get current() {
return current;
},
get loading() {
return loading;
},
get error() {
return error;
},
mutate: (value) => {
current = value;
},
refetch: (info) => {
const values = sources.map((s) => s());
return runFetcher(Array.isArray(source) ? values : values[0], Array.isArray(source) ? values : values[0], info ?? true);
},
};
}
// Implementation
export function resource(source, fetcher, options) {
return runResource(source, fetcher, options, (fn, options) => {
const sources = Array.isArray(source) ? source : [source];
const getters = () => sources.map((s) => s());
watch(getters, (values, previousValues) => {
fn(values, previousValues ?? []);
}, options);
});
}
// Implementation
export function resourcePre(source, fetcher, options) {
return runResource(source, fetcher, options, (fn, options) => {
const sources = Array.isArray(source) ? source : [source];
const getter = () => sources.map((s) => s());
watch.pre(getter, (values, previousValues) => {
fn(values, previousValues ?? []);
}, options);
});
}
resource.pre = resourcePre;
+1
View File
@@ -0,0 +1 @@
export * from "./scroll-state.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./scroll-state.svelte.js";
+122
View File
@@ -0,0 +1,122 @@
import type { MaybeGetter } from "../../internal/types.js";
export interface ScrollStateOptions {
/**
* The scroll container.
*
* Can be an HTMLElement, Window, or Document, or a getter returning one of these.
*/
element: MaybeGetter<HTMLElement | Window | Document | null | undefined>;
/**
* Debounce timeout (ms) after scrolling ends.
*
* @default 200
*/
idle?: MaybeGetter<number | undefined>;
/**
* Pixel offset thresholds for determining "arrived" state on each scroll edge.
*/
offset?: MaybeGetter<{
left?: number;
right?: number;
top?: number;
bottom?: number;
} | undefined>;
/**
* Called during scroll events.
*/
onScroll?: (e: Event) => void;
/**
* Called after scroll ends (after debounce).
*/
onStop?: (e: Event) => void;
/**
* Scroll event listener options.
*
* @default { capture: false, passive: true }
*/
eventListenerOptions?: AddEventListenerOptions;
/**
* Scroll behavior when using `scrollTo`, `scrollToTop`, or `scrollToBottom`,
*
* @default 'auto'
*/
behavior?: MaybeGetter<ScrollBehavior | undefined>;
/**
* Optional error handler.
*
* @default console.error
*/
onError?: (error: unknown) => void;
}
/**
* A reactive utility for tracking scroll position, direction,
* and edge arrival states, while supporting programmatic scrolling.
*
* @see https://vueuse.org/useScroll for the inspiration behind this utility.
* @param element
* @param options
*/
export declare class ScrollState {
#private;
element: Window | Document | HTMLElement | null | undefined;
idle: number;
offset: {
left?: number;
right?: number;
top?: number;
bottom?: number;
};
onScroll: (e: Event) => void;
onStop: (e: Event) => void;
eventListenerOptions: AddEventListenerOptions;
behavior: "auto" | "instant" | "smooth";
onError: (error: unknown) => void;
/** State */
internalX: number;
internalY: number;
get x(): number;
set x(v: number);
get y(): number;
set y(v: number);
isScrolling: boolean;
arrived: {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
};
directions: {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
};
progress: {
x: number;
y: number;
};
constructor(options: ScrollStateOptions);
/**
* Updates direction and edge arrival states based on the current scroll position.
* Takes into account writing mode, flex direction, and RTL layouts.
*/
setArrivedState: () => void;
/**
* Programmatically scroll to a specific position.
*/
scrollTo(x: number | undefined, y: number | undefined): void;
/**
* Scrolls to the top of the element.
*/
scrollToTop(): void;
/**
* Scrolls to the bottom of the element.
*/
scrollToBottom(): void;
onScrollEnd: (e: Event) => void;
onScrollEndDebounced: ((this: unknown, e: Event) => Promise<void>) & {
cancel: () => void;
runScheduledNow: () => Promise<void>;
pending: boolean;
};
}
+206
View File
@@ -0,0 +1,206 @@
import { AnimationFrames } from "../animation-frames/index.js";
import { useDebounce } from "../use-debounce/index.js";
import { useEventListener } from "../use-event-listener/index.js";
import { onMount } from "svelte";
import { extract } from "../extract/extract.svelte.js";
import { noop } from "../../internal/utils/function.js";
/**
* We have to check if the scroll amount is close enough to some threshold in order to
* more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
* numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
*/
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
/**
* A reactive utility for tracking scroll position, direction,
* and edge arrival states, while supporting programmatic scrolling.
*
* @see https://vueuse.org/useScroll for the inspiration behind this utility.
* @param element
* @param options
*/
export class ScrollState {
#options;
element = $derived(extract(this.#options.element));
idle = $derived.by(() => extract(this.#options?.idle, 200));
offset = $derived(extract(this.#options.offset, {
left: 0,
right: 0,
top: 0,
bottom: 0,
}));
onScroll = $derived(this.#options.onScroll ?? noop);
onStop = $derived(this.#options.onStop ?? noop);
eventListenerOptions = $derived(this.#options.eventListenerOptions ?? {
capture: false,
passive: true,
});
behavior = $derived(extract(this.#options.behavior, "auto"));
onError = $derived(this.#options.onError ??
((e) => {
console.error(e);
}));
/** State */
internalX = $state(0);
internalY = $state(0);
// Use a get/set pair for x and y because we want to write the value to the refs
// during a `scrollTo()` without firing additional `scrollTo()`s in the process.
#x = $derived(this.internalX);
get x() {
return this.#x;
}
set x(v) {
this.scrollTo(v, undefined);
}
#y = $derived(this.internalY);
get y() {
return this.#y;
}
set y(v) {
this.scrollTo(undefined, v);
}
isScrolling = $state(false);
arrived = $state({
left: true,
right: false,
top: true,
bottom: false,
});
directions = $state({
left: false,
right: false,
top: false,
bottom: false,
});
progress = $state({
x: 0,
y: 0,
});
constructor(options) {
this.#options = options;
useEventListener(() => this.element, "scroll", this.#onScrollHandler, this.eventListenerOptions);
useEventListener(() => this.element, "scrollend", (e) => this.onScrollEnd(e), this.eventListenerOptions);
onMount(() => {
this.setArrivedState();
});
// overkill?
new AnimationFrames(() => this.setArrivedState());
}
/**
* Updates direction and edge arrival states based on the current scroll position.
* Takes into account writing mode, flex direction, and RTL layouts.
*/
setArrivedState = () => {
if (!window || !this.element)
return;
const el = (this.element?.document?.documentElement ||
this.element?.documentElement ||
this.element);
const { display, flexDirection, direction } = getComputedStyle(el);
const directionMultiplier = direction === "rtl" ? -1 : 1;
const scrollLeft = el.scrollLeft;
if (scrollLeft !== this.internalX) {
this.directions.left = scrollLeft < this.internalX;
this.directions.right = scrollLeft > this.internalX;
}
const left = scrollLeft * directionMultiplier <= (this.offset.left || 0);
const right = scrollLeft * directionMultiplier + el.clientWidth >=
el.scrollWidth - (this.offset.right || 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
if (display === "flex" && flexDirection === "row-reverse") {
this.arrived.left = right;
this.arrived.right = left;
}
else {
this.arrived.left = left;
this.arrived.right = right;
}
this.internalX = scrollLeft;
let scrollTop = el.scrollTop;
// patch for mobile compatible
if (this.element === window.document && !scrollTop)
scrollTop = window.document.body.scrollTop;
if (scrollTop !== this.internalY) {
this.directions.top = scrollTop < this.internalY;
this.directions.bottom = scrollTop > this.internalY;
}
const top = scrollTop <= (this.offset.top || 0);
const bottom = scrollTop + el.clientHeight >=
el.scrollHeight - (this.offset.bottom || 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
/**
* reverse columns and rows behave exactly the other way around,
* bottom is treated as top and top is treated as the negative version of bottom
*/
if (display === "flex" && flexDirection === "column-reverse") {
this.arrived.top = bottom;
this.arrived.bottom = top;
}
else {
this.arrived.top = top;
this.arrived.bottom = bottom;
}
const height = el.scrollHeight - (this.offset.bottom || 0);
this.progress.y = (scrollTop / (height - el.clientHeight)) * 100;
const width = el.scrollWidth - (this.offset.left || 0);
// Math.abs for rtl support
this.progress.x = Math.abs((scrollLeft / (width - el.clientWidth)) * 100);
this.internalY = scrollTop;
};
#onScrollHandler = (e) => {
if (!window)
return;
this.setArrivedState();
this.isScrolling = true;
this.onScrollEndDebounced(e);
this.onScroll(e);
};
/**
* Programmatically scroll to a specific position.
*/
scrollTo(x, y) {
if (!window)
return;
(this.element instanceof Document ? window.document.body : this.element)?.scrollTo({
top: y ?? this.y,
left: x ?? this.x,
behavior: this.behavior,
});
const scrollContainer = this.element?.document?.documentElement ||
this.element?.documentElement ||
this.element;
if (x != null)
this.internalX = scrollContainer.scrollLeft;
if (y != null)
this.internalY = scrollContainer.scrollTop;
}
/**
* Scrolls to the top of the element.
*/
scrollToTop() {
this.scrollTo(undefined, 0);
}
/**
* Scrolls to the bottom of the element.
*/
scrollToBottom() {
if (!window)
return;
const scrollContainer = this.element?.document?.documentElement ||
this.element?.documentElement ||
this.element;
if (!scrollContainer)
return;
this.scrollTo(undefined, scrollContainer.scrollHeight);
}
onScrollEnd = (e) => {
// dedupe if support native scrollend event
if (!this.isScrolling)
return;
this.isScrolling = false;
this.directions.left = false;
this.directions.right = false;
this.directions.top = false;
this.directions.bottom = false;
this.onStop(e);
};
onScrollEndDebounced = useDebounce(this.onScrollEnd, () => this.idle);
}
+1
View File
@@ -0,0 +1 @@
export * from "./state-history.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./state-history.svelte.js";
@@ -0,0 +1,24 @@
import type { MaybeGetter, Setter } from "../../internal/types.js";
type LogEvent<T> = {
snapshot: T;
timestamp: number;
};
type StateHistoryOptions = {
capacity?: MaybeGetter<number>;
};
/**
* Tracks the change history of a value, providing undo and redo capabilities.
*
* @see {@link https://runed.dev/docs/utilities/state-history}
*/
export declare class StateHistory<T> {
#private;
log: LogEvent<T>[];
readonly canUndo: boolean;
readonly canRedo: boolean;
constructor(value: MaybeGetter<T>, set: Setter<T>, options?: StateHistoryOptions);
undo(): void;
redo(): void;
clear(): void;
}
export {};
@@ -0,0 +1,62 @@
import { watch } from "../watch/watch.svelte.js";
import { get } from "../../internal/utils/get.js";
/**
* Tracks the change history of a value, providing undo and redo capabilities.
*
* @see {@link https://runed.dev/docs/utilities/state-history}
*/
export class StateHistory {
#redoStack = $state([]);
#ignoreUpdate = false;
#set;
log = $state([]);
canUndo = $derived(this.log.length > 1);
canRedo = $derived(this.#redoStack.length > 0);
constructor(value, set, options) {
this.#redoStack = [];
this.#set = set;
this.undo = this.undo.bind(this);
this.redo = this.redo.bind(this);
const addEvent = (event) => {
this.log.push(event);
const capacity$ = get(options?.capacity);
if (capacity$ && this.log.length > capacity$) {
this.log = this.log.slice(-capacity$);
}
};
watch(() => get(value), (v) => {
if (this.#ignoreUpdate) {
this.#ignoreUpdate = false;
return;
}
addEvent({ snapshot: v, timestamp: new Date().getTime() });
this.#redoStack = [];
});
watch(() => get(options?.capacity), (c) => {
if (!c)
return;
this.log = this.log.slice(-c);
});
}
undo() {
const [prev, curr] = this.log.slice(-2);
if (!curr || !prev)
return;
this.#ignoreUpdate = true;
this.#redoStack.push(curr);
this.log.pop();
this.#set(prev.snapshot);
}
redo() {
const nextEvent = this.#redoStack.pop();
if (!nextEvent)
return;
this.#ignoreUpdate = true;
this.log.push(nextEvent);
this.#set(nextEvent.snapshot);
}
clear() {
this.log = [];
this.#redoStack = [];
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./textarea-autosize.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./textarea-autosize.svelte.js";
@@ -0,0 +1,51 @@
import type { Getter, MaybeGetter } from "../../internal/types.js";
export interface TextareaAutosizeOptions {
/**
* The textarea element to autosize.
*/
element: MaybeGetter<HTMLElement | undefined>;
/**
* The current text value of the textarea.
* Should be reactive.
*/
input: Getter<string>;
/**
* Callback triggered whenever the textarea resizes.
*/
onResize?: () => void;
/**
* Style property to update during resize. Defaults to `"height"`.
* Use `"minHeight"` to allow natural expansion without shrinking.
*
* @default "height"
*/
styleProp?: "height" | "minHeight";
/**
* Optional maximum height in pixels. If exceeded, scrolling is enabled.
*
* @default undefined (no limit)
*/
maxHeight?: number;
}
/**
* Automatically resizes a textarea element based on its content and width.
*
* Uses a hidden clone for accurate measurements without layout shift.
* Reactively updates when the input or element changes, or when the
* element is resized.
*/
export declare class TextareaAutosize {
#private;
element: HTMLElement | undefined;
input: string;
styleProp: "height" | "minHeight";
maxHeight: number | undefined;
textareaHeight: number;
textareaOldWidth: number;
constructor(options: TextareaAutosizeOptions);
/**
* Recomputes the required height based on current content and applies it
* to the textarea. If `maxHeight` is exceeded, vertical scrolling is enabled.
*/
triggerResize: () => void;
}
@@ -0,0 +1,131 @@
import { useResizeObserver } from "../use-resize-observer/index.js";
import { watch } from "../watch/index.js";
import { tick } from "svelte";
import { extract } from "../extract/index.js";
const stylesToCopy = [
"box-sizing",
"width",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"border-top-width",
"border-right-width",
"border-bottom-width",
"border-left-width",
"font-family",
"font-size",
"font-weight",
"font-style",
"letter-spacing",
"text-indent",
"text-transform",
"line-height",
"word-spacing",
"word-wrap",
"word-break",
"white-space",
];
/**
* Automatically resizes a textarea element based on its content and width.
*
* Uses a hidden clone for accurate measurements without layout shift.
* Reactively updates when the input or element changes, or when the
* element is resized.
*/
export class TextareaAutosize {
#options;
#resizeTimeout = null;
#hiddenTextarea = null;
element = $derived.by(() => extract(this.#options.element));
input = $derived.by(() => extract(this.#options.input));
styleProp = $derived.by(() => extract(this.#options.styleProp, "height"));
maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined));
textareaHeight = $state(0);
textareaOldWidth = $state(0);
constructor(options) {
this.#options = options;
// Create hidden textarea for measurements
this.#createHiddenTextarea();
watch([() => this.input, () => this.element], () => {
tick().then(() => this.triggerResize());
});
watch(() => this.textareaHeight, () => options?.onResize?.());
useResizeObserver(() => this.element, ([entry]) => {
if (!entry)
return;
const { contentRect } = entry;
if (this.textareaOldWidth === contentRect.width)
return;
this.textareaOldWidth = contentRect.width;
this.triggerResize();
});
$effect(() => {
return () => {
// Clean up
if (this.#hiddenTextarea) {
this.#hiddenTextarea.remove();
this.#hiddenTextarea = null;
}
if (this.#resizeTimeout) {
window.cancelAnimationFrame(this.#resizeTimeout);
this.#resizeTimeout = null;
}
};
});
}
// Copy all the styles that affect text layout
#createHiddenTextarea() {
// Create a hidden textarea that will be used for measurements
// This avoids layout shifts caused by manipulating the actual textarea
if (typeof window === "undefined")
return;
this.#hiddenTextarea = document.createElement("textarea");
const style = this.#hiddenTextarea.style;
// Make it invisible but keep same text layout properties
style.visibility = "hidden";
style.position = "absolute";
style.overflow = "hidden";
style.height = "0";
style.top = "0";
style.left = "-9999px";
document.body.appendChild(this.#hiddenTextarea);
}
#copyStyles() {
if (!this.element || !this.#hiddenTextarea)
return;
const computed = window.getComputedStyle(this.element);
for (const style of stylesToCopy) {
this.#hiddenTextarea.style.setProperty(style, computed.getPropertyValue(style));
}
// Ensure the width matches exactly
this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`;
}
/**
* Recomputes the required height based on current content and applies it
* to the textarea. If `maxHeight` is exceeded, vertical scrolling is enabled.
*/
triggerResize = () => {
if (!this.element || !this.#hiddenTextarea)
return;
// Copy current styles and content to hidden textarea
this.#copyStyles();
this.#hiddenTextarea.value = this.input || "";
// Measure the hidden textarea
const scrollHeight = this.#hiddenTextarea.scrollHeight;
// Apply the height, respecting maxHeight if set
let newHeight = scrollHeight;
if (this.maxHeight && newHeight > this.maxHeight) {
newHeight = this.maxHeight;
this.element.style.overflowY = "auto";
}
else {
this.element.style.overflowY = "hidden";
}
// Only update if height actually changed
if (this.textareaHeight !== newHeight) {
this.textareaHeight = newHeight;
this.element.style[this.styleProp] = `${newHeight}px`;
}
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./throttled.svelte.js";
+1
View File
@@ -0,0 +1 @@
export * from "./throttled.svelte.js";
+8
View File
@@ -0,0 +1,8 @@
import type { Getter, MaybeGetter } from "../../internal/types.js";
export declare class Throttled<T> {
#private;
constructor(getter: Getter<T>, wait?: MaybeGetter<number>);
get current(): T;
cancel(): void;
setImmediately(v: T): void;
}
+26
View File
@@ -0,0 +1,26 @@
import { useThrottle } from "../use-throttle/use-throttle.svelte.js";
import { watch } from "../watch/watch.svelte.js";
import { noop } from "../../internal/utils/function.js";
export class Throttled {
#current = $state();
#throttleFn;
constructor(getter, wait = 250) {
this.#current = getter(); // Immediately set the initial value
this.#throttleFn = useThrottle(() => {
this.#current = getter();
}, wait);
watch(getter, () => {
this.#throttleFn()?.catch(noop);
});
}
get current() {
return this.#current;
}
cancel() {
this.#throttleFn.cancel();
}
setImmediately(v) {
this.cancel();
this.#current = v;
}
}

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