Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: IsFocusWithin #103

Merged
merged 13 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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