Skip to content

Commit

Permalink
feat: Configurable Globals (#186)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Dec 20, 2024
1 parent 9e28ddd commit 6f8c3c4
Show file tree
Hide file tree
Showing 30 changed files with 482 additions and 212 deletions.
5 changes: 5 additions & 0 deletions .changeset/purple-rabbits-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

feat: Configurable globals (`window`, `document`, `navigator`, etc.)
34 changes: 34 additions & 0 deletions packages/runed/src/lib/internal/configurable-globals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// configurable-globals.ts
import { BROWSER } from "esm-env";

export type ConfigurableWindow = {
/** Provide a custom `window` object to use in place of the global `window` object. */
window?: typeof globalThis & Window;
};

export type ConfigurableDocument = {
/** Provide a custom `document` object to use in place of the global `document` object. */
document?: Document;
};

export type ConfigurableDocumentOrShadowRoot = {
/*
* Specify a custom `document` instance or a shadow root, e.g. working with iframes or in testing environments.
*/
document?: DocumentOrShadowRoot;
};

export type ConfigurableNavigator = {
/** Provide a custom `navigator` object to use in place of the global `navigator` object. */
navigator?: Navigator;
};

export type ConfigurableLocation = {
/** Provide a custom `location` object to use in place of the global `location` object. */
location?: Location;
};

export const defaultWindow = /* #__PURE__ */ BROWSER ? window : undefined;
export const defaultDocument = /* #__PURE__ */ BROWSER ? window.document : undefined;
export const defaultNavigator = /* #__PURE__ */ BROWSER ? window.navigator : undefined;
export const defaultLocation = /* #__PURE__ */ BROWSER ? window.location : undefined;
5 changes: 4 additions & 1 deletion packages/runed/src/lib/internal/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export type Getter<T> = () => T;
export type MaybeGetter<T> = T | Getter<T>;
export type MaybeElementGetter<T extends Element = HTMLElement> = MaybeGetter<T | null | undefined>;
export type Setter<T> = (value: T) => void;

export type Expand<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
export type WritableProperties<T> = {
-readonly [P in keyof T]: T[P];
};
20 changes: 20 additions & 0 deletions packages/runed/src/lib/internal/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Handles getting the active element in a document or shadow root.
* If the active element is within a shadow root, it will traverse the shadow root
* to find the active element.
* If not, it will return the active element in the document.
*
* @param document A document or shadow root to get the active element from.
* @returns The active element in the document or shadow root.
*/
export function getActiveElement(document: DocumentOrShadowRoot): HTMLElement | null {
let activeElement = document.activeElement as HTMLElement | null;

while (activeElement?.shadowRoot) {
const node = activeElement.shadowRoot.activeElement as HTMLElement | null;
if (node === activeElement) break;
else activeElement = node;
}

return activeElement;
}
4 changes: 1 addition & 3 deletions packages/runed/src/lib/internal/utils/function.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export function noop(): void {
// noop
}
export function noop(): void {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { untrack } from "svelte";
import { extract } from "../extract/index.js";
import type { MaybeGetter } from "$lib/internal/types.js";
import { defaultWindow, type ConfigurableWindow } from "$lib/internal/configurable-globals.js";

type RafCallbackParams = {
/** The number of milliseconds since the last frame. */
Expand All @@ -12,7 +13,7 @@ type RafCallbackParams = {
timestamp: DOMHighResTimeStamp;
};

export type AnimationFramesOptions = {
export type AnimationFramesOptions = ConfigurableWindow & {
/**
* Start calling requestAnimationFrame immediately.
*
Expand All @@ -39,11 +40,12 @@ export class AnimationFrames {
#fpsLimit = $derived(extract(this.#fpsLimitOption) ?? 0);
#previousTimestamp: number | null = null;
#frame: number | null = null;

#fps = $state(0);
#running = $state(false);
#window = defaultWindow;

constructor(callback: (params: RafCallbackParams) => void, options: AnimationFramesOptions = {}) {
if (options.window) this.#window = options.window;
this.#fpsLimitOption = options.fpsLimit;
this.#callback = callback;

Expand All @@ -61,7 +63,7 @@ export class AnimationFrames {
}

#loop(timestamp: DOMHighResTimeStamp): void {
if (!this.#running) return;
if (!this.#running || !this.#window) return;

if (this.#previousTimestamp === null) {
this.#previousTimestamp = timestamp;
Expand All @@ -70,26 +72,27 @@ export class AnimationFrames {
const delta = timestamp - this.#previousTimestamp;
const fps = 1000 / delta;
if (this.#fpsLimit && fps > this.#fpsLimit) {
this.#frame = requestAnimationFrame(this.#loop.bind(this));
this.#frame = this.#window.requestAnimationFrame(this.#loop.bind(this));
return;
}

this.#fps = fps;
this.#previousTimestamp = timestamp;
this.#callback({ delta, timestamp });
this.#frame = requestAnimationFrame(this.#loop.bind(this));
this.#frame = this.#window.requestAnimationFrame(this.#loop.bind(this));
}

start(): void {
if (!this.#window) return;
this.#running = true;
this.#previousTimestamp = 0;
this.#frame = requestAnimationFrame(this.#loop.bind(this));
this.#frame = this.#window.requestAnimationFrame(this.#loop.bind(this));
}

stop(): void {
if (!this.#frame) return;
if (!this.#frame || !this.#window) return;
this.#running = false;
cancelAnimationFrame(this.#frame);
this.#window.cancelAnimationFrame(this.#frame);
this.#frame = null;
}

Expand Down
34 changes: 18 additions & 16 deletions packages/runed/src/lib/utilities/ElementRect/ElementRect.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { extract } from "../extract/extract.svelte.js";
import { useMutationObserver } from "../useMutationObserver/useMutationObserver.svelte.js";
import { useResizeObserver } from "../useResizeObserver/useResizeObserver.svelte.js";
import type { MaybeGetter } from "$lib/internal/types.js";
import type { MaybeElementGetter, WritableProperties } from "$lib/internal/types.js";
import type { ConfigurableWindow } from "$lib/internal/configurable-globals.js";

type Rect = Omit<DOMRect, "toJSON">;
type Rect = WritableProperties<Omit<DOMRect, "toJSON">>;

export type ElementRectOptions = {
export type ElementRectOptions = ConfigurableWindow & {
initialRect?: DOMRect;
};

Expand All @@ -32,7 +33,7 @@ export class ElementRect {
left: 0,
});

constructor(node: MaybeGetter<HTMLElement | undefined | null>, options: ElementRectOptions = {}) {
constructor(node: MaybeElementGetter, options: ElementRectOptions = {}) {
this.#rect = {
width: options.initialRect?.width ?? 0,
height: options.initialRect?.height ?? 0,
Expand All @@ -48,21 +49,22 @@ export class ElementRect {
const update = () => {
if (!el) return;
const rect = el.getBoundingClientRect();
this.#rect = {
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
};
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);
useResizeObserver(() => el, update, { window: options.window });
$effect(update);
useMutationObserver(() => el, update, { attributeFilter: ["style", "class"] });
useMutationObserver(() => el, update, {
attributeFilter: ["style", "class"],
window: options.window,
});
}

get x(): number {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { MaybeGetter } from "$lib/internal/types.js";
import { defaultWindow, type ConfigurableWindow } from "$lib/internal/configurable-globals.js";
import type { MaybeElementGetter } from "$lib/internal/types.js";
import { get } from "$lib/internal/utils/get.js";

export type ElementSizeOptions = {
export type ElementSizeOptions = ConfigurableWindow & {
initialSize?: {
width: number;
height: number;
Expand All @@ -26,20 +27,20 @@ export class ElementSize {
height: 0,
});

constructor(
node: MaybeGetter<HTMLElement | undefined | null>,
options: ElementSizeOptions = { box: "border-box" }
) {
constructor(node: MaybeElementGetter, options: ElementSizeOptions = { box: "border-box" }) {
const window = options.window ?? defaultWindow;

this.#size = {
width: options.initialSize?.width ?? 0,
height: options.initialSize?.height ?? 0,
};

$effect(() => {
if (!window) return;
const node$ = get(node);
if (!node$) return;

const observer = new ResizeObserver((entries) => {
const observer = new window.ResizeObserver((entries) => {
for (const entry of entries) {
const boxSize =
options.box === "content-box" ? entry.contentBoxSize : entry.borderBoxSize;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import { extract } from "../extract/extract.svelte.js";
import { activeElement } from "../activeElement/activeElement.svelte.js";
import type { MaybeGetter } from "$lib/internal/types.js";
import type { MaybeElementGetter } from "$lib/internal/types.js";
import {
type ConfigurableDocumentOrShadowRoot,
type ConfigurableWindow,
} from "$lib/internal/configurable-globals.js";
import { useActiveElement } from "../useActiveElement/useActiveElement.svelte.js";

type IsFocusWithinOptions = ConfigurableDocumentOrShadowRoot & ConfigurableWindow;

/**
* Tracks whether the focus is within a target element.
* @see {@link https://runed.dev/docs/utilities/is-focus-within}
*/
export class IsFocusWithin {
#node: MaybeGetter<HTMLElement | undefined | null>;
#node: MaybeElementGetter;
#target = $derived.by(() => extract(this.#node));
#activeElement: ReturnType<typeof useActiveElement>;

constructor(node: MaybeGetter<HTMLElement | undefined | null>) {
constructor(node: MaybeElementGetter, options: IsFocusWithinOptions = {}) {
this.#node = node;
this.#activeElement = useActiveElement({ document: options.document, window: options.window });
}

readonly current = $derived.by(() => {
if (!this.#target || !activeElement.current) return false;
return this.#target.contains(activeElement.current);
if (!this.#target || !this.#activeElement.current) return false;
return this.#target.contains(this.#activeElement.current);
});
}
Loading

0 comments on commit 6f8c3c4

Please sign in to comment.