Skip to content

Commit

Permalink
breaking: deprecate Readable and useActiveElement -> `ActiveEleme…
Browse files Browse the repository at this point in the history
…nt` (#193)

Co-authored-by: Hunter Johnston <[email protected]>
  • Loading branch information
abdel-17 and huntabyte authored Dec 22, 2024
1 parent b61eaf2 commit 6fd5ad6
Show file tree
Hide file tree
Showing 18 changed files with 454 additions and 321 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-toes-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

breaking: deprecate `Readable` in favor of `createSubscriber` from Svelte
5 changes: 5 additions & 0 deletions .changeset/eighty-pandas-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

breaking: replace `useActiveElement` with `ActiveElement` for custom `DocumentOrShadowRoot` options
125 changes: 125 additions & 0 deletions packages/runed/src/lib/internal/utils/dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach } from "vitest";
import { getActiveElement } from "./dom.js";

describe("getActiveElement", () => {
class MockShadowRoot implements Partial<ShadowRoot> {
private _activeElement: Element | null = null;

get activeElement() {
return this._activeElement;
}

setActiveElement(element: Element | null | MockElement) {
this._activeElement = element as unknown as Element;
}
}

class MockElement implements Partial<HTMLElement> {
private _shadowRoot: MockShadowRoot | null = null;

constructor(public tagName: string = "DIV") {}

get shadowRoot() {
return this._shadowRoot as unknown as ShadowRoot;
}

attachShadow(_: ShadowRootInit) {
this._shadowRoot = new MockShadowRoot();
return this._shadowRoot as unknown as ShadowRoot;
}
}

class MockDocument implements Partial<DocumentOrShadowRoot> {
private _activeElement: Element | null = null;

get activeElement() {
return this._activeElement;
}

setActiveElement(element: Element | null | MockElement) {
this._activeElement = element as unknown as Element;
}
}

let mockDocument: MockDocument;

beforeEach(() => {
mockDocument = new MockDocument();
});

it("returns null when document has no active element", () => {
expect(getActiveElement(mockDocument as any)).toBeNull();
});

it("returns the active element when it has no shadow root", () => {
const mockElement = new MockElement();
mockDocument.setActiveElement(mockElement);

expect(getActiveElement(mockDocument as any)).toBe(mockElement);
});

it("returns the active element within a shadow root", () => {
const shadowElement = new MockElement("BUTTON");
const hostElement = new MockElement();

hostElement.attachShadow({ mode: "open" });
(hostElement.shadowRoot as unknown as MockShadowRoot).setActiveElement(shadowElement);
mockDocument.setActiveElement(hostElement);

expect(getActiveElement(mockDocument as any)).toBe(shadowElement);
});

it("returns the deepest active element in nested shadow roots", () => {
const deepestElement = new MockElement("INPUT");
const middleElement = new MockElement();
const topElement = new MockElement();

middleElement.attachShadow({ mode: "open" });
topElement.attachShadow({ mode: "open" });

(middleElement.shadowRoot as unknown as MockShadowRoot).setActiveElement(deepestElement);
(topElement.shadowRoot as unknown as MockShadowRoot).setActiveElement(middleElement);
mockDocument.setActiveElement(topElement);

expect(getActiveElement(mockDocument as any)).toBe(deepestElement);
});

it("handles when inner shadow root has no active element", () => {
const hostElement = new MockElement();
hostElement.attachShadow({ mode: "open" });
mockDocument.setActiveElement(hostElement);

expect(getActiveElement(mockDocument as any)).toBeNull();
});

it("works when starting from a shadow root instead of document", () => {
const innerElement = new MockElement("SPAN");
const mockShadowRoot = new MockShadowRoot();
mockShadowRoot.setActiveElement(innerElement);

expect(getActiveElement(mockShadowRoot as any)).toBe(innerElement);
});

it("handles nested shadow roots when starting from middle shadow root", () => {
const deepestElement = new MockElement("BUTTON");
const middleElement = new MockElement();

middleElement.attachShadow({ mode: "open" });
(middleElement.shadowRoot as unknown as MockShadowRoot).setActiveElement(deepestElement);

const mockShadowRoot = new MockShadowRoot();
mockShadowRoot.setActiveElement(middleElement);

expect(getActiveElement(mockShadowRoot as any)).toBe(deepestElement);
});

it("breaks infinite loops if activeElement references itself", () => {
const element = new MockElement();
element.attachShadow({ mode: "open" });
(element.shadowRoot as unknown as MockShadowRoot).setActiveElement(element);
mockDocument.setActiveElement(element);

expect(getActiveElement(mockDocument as any)).toBe(element);
});
});
6 changes: 3 additions & 3 deletions packages/runed/src/lib/internal/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { defaultDocument } from "../configurable-globals.js";
* @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;
export function getActiveElement(document: DocumentOrShadowRoot): Element | null {
let activeElement = document.activeElement;

while (activeElement?.shadowRoot) {
const node = activeElement.shadowRoot.activeElement as HTMLElement | null;
const node = activeElement.shadowRoot.activeElement;
if (node === activeElement) break;
else activeElement = node;
}
Expand Down
35 changes: 15 additions & 20 deletions packages/runed/src/lib/test/util.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
import { flushSync } from "svelte";
import { test, vi } from "vitest";

export function testWithEffect(name: string, fn: () => void | Promise<void>) {
test(name, async () => {
let promise: void | Promise<void>;
const cleanup = $effect.root(() => {
promise = fn();
});
export function testWithEffect(name: string, fn: () => void | Promise<void>): void {
test(name, () => effectRootScope(fn));
}

try {
await promise!;
} finally {
cleanup();
}
export function effectRootScope(fn: () => void | Promise<void>): void | Promise<void> {
let promise!: void | Promise<void>;
const cleanup = $effect.root(() => {
promise = fn();
});

if (promise instanceof Promise) {
return promise.finally(cleanup);
} else {
cleanup();
}
}

export function vitestSetTimeoutWrapper(fn: () => void, timeout: number) {
setTimeout(async () => {
export function vitestSetTimeoutWrapper(fn: () => void, timeout: number): void {
setTimeout(() => {
fn();
}, timeout + 1);

vi.advanceTimersByTime(timeout);
}

export function focus(node: HTMLElement | null | undefined) {
if (node) {
flushSync(() => node.focus());
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,25 @@
import { extract } from "../extract/extract.svelte.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";
import { ActiveElement, type ActiveElementOptions } from "../activeElement/activeElement.svelte.js";
import { extract } from "../extract/extract.svelte.js";

type IsFocusWithinOptions = ConfigurableDocumentOrShadowRoot & ConfigurableWindow;
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 class IsFocusWithin {
#node: MaybeElementGetter;
#target = $derived.by(() => extract(this.#node));
#activeElement: ReturnType<typeof useActiveElement>;
readonly #node: MaybeElementGetter;
readonly #activeElement: ActiveElement;

constructor(node: MaybeElementGetter, options: IsFocusWithinOptions = {}) {
this.#node = node;
this.#activeElement = useActiveElement({ document: options.document, window: options.window });
this.#activeElement = new ActiveElement(options);
}

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

0 comments on commit 6fd5ad6

Please sign in to comment.