INIT
This commit is contained in:
+18
@@ -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;
|
||||
+38
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./active-element.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./active-element.svelte.js";
|
||||
+40
@@ -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 {};
|
||||
+72
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./animation-frames.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./animation-frames.svelte.js";
|
||||
+13
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { boolAttr } from "./bool-attr.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { boolAttr } from "./bool-attr.js";
|
||||
+42
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { Context } from "./context.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { Context } from "./context.js";
|
||||
+52
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./debounced.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./debounced.svelte.js";
|
||||
+31
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { ElementRect } from "./element-rect.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ElementRect } from "./element-rect.svelte.js";
|
||||
+34
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./element-size.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./element-size.svelte.js";
|
||||
+15
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { extract } from "./extract.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { extract } from "./extract.svelte.js";
|
||||
Generated
Vendored
+37
@@ -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;
|
||||
}
|
||||
Generated
Vendored
+78
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./finite-state-machine.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./finite-state-machine.svelte.js";
|
||||
+34
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./is-document-visible.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./is-document-visible.svelte.js";
|
||||
Generated
Vendored
+14
@@ -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;
|
||||
}
|
||||
Generated
Vendored
+27
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./is-focus-within.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./is-focus-within.svelte.js";
|
||||
+13
@@ -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;
|
||||
}
|
||||
+20
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./is-idle.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./is-idle.svelte.js";
|
||||
+38
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./is-in-viewport.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./is-in-viewport.svelte.js";
|
||||
+14
@@ -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;
|
||||
}
|
||||
+25
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./is-mounted.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./is-mounted.svelte.js";
|
||||
+11
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { onCleanup } from "./on-cleanup.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { onCleanup } from "./on-cleanup.svelte.js";
|
||||
+9
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./on-click-outside.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./on-click-outside.svelte.js";
|
||||
+63
@@ -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;
|
||||
};
|
||||
+182
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./persisted-state.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./persisted-state.svelte.js";
|
||||
+29
@@ -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 {};
|
||||
+117
@@ -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
@@ -0,0 +1 @@
|
||||
export { PressedKeys } from "./pressed-keys.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { PressedKeys } from "./pressed-keys.svelte.js";
|
||||
+20
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./previous.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./previous.svelte.js";
|
||||
+11
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./resource.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./resource.svelte.js";
|
||||
+14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./scroll-state.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./scroll-state.svelte.js";
|
||||
+122
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./state-history.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./state-history.svelte.js";
|
||||
+24
@@ -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 {};
|
||||
+62
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./textarea-autosize.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./textarea-autosize.svelte.js";
|
||||
Generated
Vendored
+51
@@ -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;
|
||||
}
|
||||
+131
@@ -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
@@ -0,0 +1 @@
|
||||
export * from "./throttled.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./throttled.svelte.js";
|
||||
+8
@@ -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
@@ -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
Reference in New Issue
Block a user