Skip to content

Commit

Permalink
feat: IsFocusWithin (#103)
Browse files Browse the repository at this point in the history
Co-authored-by: CokaKoala <31664583+AdrianGonz97@users.noreply.github.com>
  • Loading branch information
huntabyte and AdrianGonz97 authored Jul 1, 2024
1 parent 3f5a197 commit 6353651
Show file tree
Hide file tree
Showing 24 changed files with 547 additions and 130 deletions.
5 changes: 5 additions & 0 deletions .changeset/light-walls-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

feat: `IsFocusWithin`
14 changes: 9 additions & 5 deletions packages/runed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,30 @@
"!dist/**/*.spec.*"
],
"peerDependencies": {
"svelte": "^5.0.0"
"svelte": "^5.0.0-next.1"
},
"devDependencies": {
"@sveltejs/kit": "^2.5.3",
"@sveltejs/package": "^2.3.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@testing-library/dom": "^10.2.0",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/svelte": "^5.2.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20.10.6",
"@vitest/coverage-v8": "^1.5.1",
"@vitest/ui": "^1.6.0",
"jsdom": "^24.0.0",
"publint": "^0.1.9",
"svelte": "^5.0.0-next.130",
"resize-observer-polyfill": "^1.5.1",
"svelte": "^5.0.0-next.167",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3",
"vitest": "^1.5.1"
},
"dependencies": {
"esm-env": "^1.0.0",
"nanoid": "^5.0.4"
"esm-env": "^1.0.0"
}
}
12 changes: 0 additions & 12 deletions packages/runed/playwright.config.ts

This file was deleted.

89 changes: 89 additions & 0 deletions packages/runed/setupTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import "@testing-library/svelte/vitest";
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
import type { Navigation, Page } from "@sveltejs/kit";
import { readable } from "svelte/store";
import type * as environment from "$app/environment";
import type * as navigation from "$app/navigation";
import type * as stores from "$app/stores";

// Mock SvelteKit runtime module $app/environment
vi.mock("$app/environment", (): typeof environment => ({
browser: false,
dev: true,
building: false,
version: "any",
}));

// Mock SvelteKit runtime module $app/navigation
vi.mock("$app/navigation", (): typeof navigation => ({
afterNavigate: () => {},
beforeNavigate: () => {},
disableScrollHandling: () => {},
goto: () => Promise.resolve(),
invalidate: () => Promise.resolve(),
invalidateAll: () => Promise.resolve(),
preloadData: () => Promise.resolve({ type: "loaded" as const, status: 200, data: {} }),
preloadCode: () => Promise.resolve(),
onNavigate: () => {},
pushState: () => {},
replaceState: () => {},
}));

// Mock SvelteKit runtime module $app/stores
vi.mock("$app/stores", (): typeof stores => {
const getStores: typeof stores.getStores = () => {
const navigating = readable<Navigation | null>(null);
const page = readable<Page>({
url: new URL("http://localhost"),
params: {},
route: {
id: null,
},
status: 200,
error: null,
data: {},
form: undefined,
state: {},
});
const updated = { subscribe: readable(false).subscribe, check: async () => false };

return { navigating, page, updated };
};

const page: typeof stores.page = {
subscribe(fn) {
return getStores().page.subscribe(fn);
},
};
const navigating: typeof stores.navigating = {
subscribe(fn) {
return getStores().navigating.subscribe(fn);
},
};
const updated: typeof stores.updated = {
subscribe(fn) {
return getStores().updated.subscribe(fn);
},
check: async () => false,
};

return {
getStores,
navigating,
page,
updated,
};
});

// eslint-disable-next-line ts/no-require-imports
globalThis.ResizeObserver = require("resize-observer-polyfill");
Element.prototype.scrollIntoView = () => {};
// eslint-disable-next-line ts/no-explicit-any
Element.prototype.hasPointerCapture = (() => {}) as any;

// @ts-expect-error - shut it
globalThis.window.CSS.supports = (_property: string, _value: string) => true;

globalThis.document.elementsFromPoint = () => [];
globalThis.document.elementFromPoint = () => null;
7 changes: 7 additions & 0 deletions packages/runed/src/lib/test/util.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { flushSync } from "svelte";
import { test, vi } from "vitest";

export function testWithEffect(name: string, fn: () => void | Promise<void>) {
Expand All @@ -22,3 +23,9 @@ export function vitestSetTimeoutWrapper(fn: () => void, timeout: number) {

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
@@ -0,0 +1,21 @@
import { extract } from "../extract/extract.js";
import { activeElement } from "../activeElement/activeElement.svelte.js";
import type { MaybeGetter } from "$lib/internal/types.js";

/**
* 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>;
#target = $derived.by(() => extract(this.#node));

constructor(node: MaybeGetter<HTMLElement | undefined>) {
this.#node = node;
}

readonly current = $derived.by(() => {
if (!this.#target || !activeElement.current) return false;
return this.#target.contains(activeElement.current);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { render } from "@testing-library/svelte/svelte5";
import { describe, expect, it } from "vitest";
import { userEvent } from "@testing-library/user-event";
import TestIsFocusWithin from "./TestIsFocusWithin.svelte";
import { focus } from "$lib/test/util.svelte.js";

function setup() {
const user = userEvent.setup();
const result = render(TestIsFocusWithin);
const input = result.getByTestId("input");
const submit = result.getByTestId("submit");
const outside = result.getByTestId("outside");
const current = result.getByTestId("current");

return {
...result,
user,
input,
submit,
outside,
current,
};
}

describe("IsFocusWithin", () => {
it("should be false on initial render", async () => {
const { current } = setup();
expect(current).toHaveTextContent("false");
});

it("should be true when any child of the target is focused", async () => {
const { user, input, current, submit } = setup();
expect(current).toHaveTextContent("false");
await user.click(input);
expect(current).toHaveTextContent("true");
focus(submit);
expect(submit).toHaveFocus();
expect(current).toHaveTextContent("true");
});

it("should be false when focus leaves the target after being true", async () => {
const { user, input, current, outside } = setup();
await user.click(input);
expect(current).toHaveTextContent("true");
focus(outside);
expect(outside).toHaveFocus();
expect(current).toHaveTextContent("false");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import { IsFocusWithin } from "./IsFocusWithin.svelte.js";
let formElement = $state<HTMLElement>();
const focusWithinForm = new IsFocusWithin(() => formElement);
</script>

<span data-testid="current">{focusWithinForm.current}</span>
<form bind:this={formElement}>
<input type="text" data-testid="input" />
<button data-testid="submit">btn</button>
</form>
<button data-testid="outside">outside</button>
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/IsFocusWithin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./IsFocusWithin.svelte.js";
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class MediaQuery {
}

get matches(): boolean | undefined {
if ($effect.active() && !this.#effectRegistered) {
if ($effect.tracking() && !this.#effectRegistered) {
this.#matches = this.#mediaQueryList.matches;

// If we are in an effect and this effect has not been registered yet
Expand All @@ -62,7 +62,7 @@ export class MediaQuery {

return () => (this.#effectRegistered = false);
});
} else if (!$effect.active() && browser) {
} else if (!$effect.tracking() && browser) {
// Otherwise, just match media to get the current value
this.#matches = this.#mediaQueryList.matches;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class Readable<T> {
#stop: VoidFunction | null = null;

get current(): T {
if ($effect.active()) {
if ($effect.tracking()) {
$effect(() => {
this.#subscribers++;
if (this.#subscribers === 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { activeElement } from "./index.js";
import { testWithEffect } from "$lib/test/util.svelte.js";

// Skipping because testing is weird
describe("useActiveElement", () => {
describe("activeElement", () => {
testWithEffect("initializes with `document.activeElement`", () => {
expect(activeElement.current).toBe(document.activeElement);
});
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from "./useMutationObserver/index.js";
export * from "./useResizeObserver/index.js";
export * from "./AnimationFrames/index.js";
export * from "./useIntersectionObserver/index.js";
export * from "./IsFocusWithin/index.js";
16 changes: 14 additions & 2 deletions packages/runed/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
"strict": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true
}
"noUncheckedIndexedAccess": true,
"types": ["vitest/globals"]
},
"include": [
"./.svelte-kit/ambient.d.ts",
"./.svelte-kit/non-ambient.d.ts",
"./.svelte-kit/types/**/$types.d.ts",
"./src/**/*.js",
"./src/**/*.ts",
"./src/**/*.svelte",
"./vite.config.ts",
"./svelte.config.js",
"./setupTest.ts"
]
}
23 changes: 19 additions & 4 deletions packages/runed/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import process from "node:process";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vitest/config";
import { svelteTesting } from "@testing-library/svelte/vite";
import type { Plugin } from "vite";

const vitestBrowserConditionPlugin: Plugin = {
name: "vite-plugin-vitest-browser-condition",
configResolved({ resolve }: { resolve: { conditions: string[] } }) {
if (process.env.VITEST) {
resolve.conditions.unshift("browser");
}
},
};

export default defineConfig({
plugins: [sveltekit()],
plugins: [vitestBrowserConditionPlugin, sveltekit(), svelteTesting()],
test: {
include: ["src/**/*.{test,test.svelte,spec}.{js,ts}"],
environment: "jsdom",
},
resolve: {
conditions: ["browser"],
includeSource: ["src/**/*.{js,ts,svelte}"],
setupFiles: ["./setupTest.ts"],
globals: true,
coverage: {
exclude: ["./setupTest.ts"],
},
},
});
Loading

0 comments on commit 6353651

Please sign in to comment.