diff --git a/.changeset/active-element-solid2-migration.md b/.changeset/active-element-solid2-migration.md new file mode 100644 index 000000000..5f8139a51 --- /dev/null +++ b/.changeset/active-element-solid2-migration.md @@ -0,0 +1,18 @@ +--- +"@solid-primitives/active-element": major +--- + +Migrate to Solid.js v2.0 (beta.12) + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.12` and `@solidjs/web@^2.0.0-beta.12` are now required. + +- `makeFocusListener` and `createFocusSignal` have moved to `@solid-primitives/focus`. Import them from there instead: + ```ts + // Before + import { makeFocusListener, createFocusSignal } from "@solid-primitives/active-element"; + // After + import { makeFocusListener, createFocusSignal } from "@solid-primitives/focus"; + ``` +- `isServer` is now sourced from `@solidjs/web` internally (no user-facing API change) diff --git a/.changeset/autofocus-solid2-migration.md b/.changeset/autofocus-solid2-migration.md deleted file mode 100644 index a9c43b560..000000000 --- a/.changeset/autofocus-solid2-migration.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@solid-primitives/autofocus": major ---- - -Migrate to Solid.js v2.0 (beta.10) - -## Breaking Changes - -**Peer dependencies**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. - -- `autofocus` is now a **ref callback factory** (`use:autofocus` directive removed; Solid 2.0 no longer supports `use:` directives): - ```tsx - // Before - - // After - - - // Before - - // After - - ``` -- `JSX` type is now imported from `@solidjs/web` (was `solid-js`) -- `onMount` replaced by `onSettled` from `solid-js` -- `createAutofocus` uses split `createEffect(compute, apply)` form with proper timeout cleanup on re-focus diff --git a/.changeset/focus-solid2-migration.md b/.changeset/focus-solid2-migration.md new file mode 100644 index 000000000..fb95c2c84 --- /dev/null +++ b/.changeset/focus-solid2-migration.md @@ -0,0 +1,40 @@ +--- +"@solid-primitives/focus": major +--- + +Migrate to Solid.js v2.0 (beta.12) + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.12` and `@solidjs/web@^2.0.0-beta.12` are now required. + +- `autofocus` is now a **ref callback factory** (`use:autofocus` directive removed; Solid 2.0 no longer supports `use:` directives): + ```tsx + // Before + + // After + + + // Before + + // After + + ``` +- `JSX` type is now imported from `@solidjs/web` (was `solid-js`) +- `onMount` replaced by `onSettled` from `solid-js` +- `createAutofocus` uses split `createEffect(compute, apply)` form with proper timeout cleanup on re-focus + +## New Primitives + +`makeFocusListener` and `createFocusSignal` have moved here from `@solid-primitives/active-element`: + +- **`makeFocusListener(target, callback, useCapture?)`** — attaches `focus`/`blur` listeners to an element, calling `callback` with the new boolean focus state. Returns a cleanup function. + ```ts + const clear = makeFocusListener(el, isFocused => console.log(isFocused)); + clear(); // remove listeners + ``` +- **`createFocusSignal(target)`** — reactive signal that tracks whether `target` is focused. + ```ts + const isFocused = createFocusSignal(() => el); + isFocused(); // boolean + ``` diff --git a/.changeset/pre.json b/.changeset/pre.json index ae0287918..c41689783 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -5,7 +5,7 @@ "@solid-primitives/active-element": "2.1.5", "@solid-primitives/analytics": "0.2.1", "@solid-primitives/audio": "1.4.4", - "@solid-primitives/autofocus": "0.1.4", + "@solid-primitives/focus": "0.1.4", "@solid-primitives/bounds": "0.1.5", "@solid-primitives/broadcast-channel": "0.1.1", "@solid-primitives/clipboard": "1.6.4", diff --git a/README.md b/README.md index 1ddd4dda8..210b3535f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The goal of Solid Primitives is to wrap client and server side functionality to |----|----|----|----|----|----| |

*Inputs*

| |[active-element](https://github.com/solidjs-community/solid-primitives/tree/main/packages/active-element#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createActiveElement](https://github.com/solidjs-community/solid-primitives/tree/main/packages/active-element#createactiveelement)
[createFocusSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/active-element#createfocussignal)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/active-element?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/active-element)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/active-element?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/active-element)|| -|[autofocus](https://github.com/solidjs-community/solid-primitives/tree/main/packages/autofocus#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[autofocus](https://github.com/solidjs-community/solid-primitives/tree/main/packages/autofocus#autofocus)
[createAutofocus](https://github.com/solidjs-community/solid-primitives/tree/main/packages/autofocus#createautofocus)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/autofocus?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/autofocus)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/autofocus?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/autofocus)|✓| +|[focus](https://github.com/solidjs-community/solid-primitives/tree/main/packages/focus#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[autofocus](https://github.com/solidjs-community/solid-primitives/tree/main/packages/focus#autofocus)
[createAutofocus](https://github.com/solidjs-community/solid-primitives/tree/main/packages/focus#createautofocus)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/focus?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/focus)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/focus?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/focus)|✓| |[input-mask](https://github.com/solidjs-community/solid-primitives/tree/main/packages/input-mask#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createInputMask](https://github.com/solidjs-community/solid-primitives/tree/main/packages/input-mask#createinputmask)
[createMaskPattern](https://github.com/solidjs-community/solid-primitives/tree/main/packages/input-mask#createmaskpattern)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/input-mask?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/input-mask)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/input-mask?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/input-mask)|✓| |[keyboard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[useKeyDownList](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#usekeydownlist)
[useCurrentlyHeldKey](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#usecurrentlyheldkey)
[useKeyDownSequence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#usekeydownsequence)
[createKeyHold](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#createkeyhold)
[createShortcut](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#createshortcut)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/keyboard?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/keyboard)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/keyboard?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/keyboard)|| |[mouse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/mouse#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createMousePosition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/mouse#createmouseposition)
[createPositionToElement](https://github.com/solidjs-community/solid-primitives/tree/main/packages/mouse#createpositiontoelement)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/mouse?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/mouse)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/mouse?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/mouse)|| diff --git a/packages/active-element/package.json b/packages/active-element/package.json index 0c43a6a2a..2230c4aa4 100644 --- a/packages/active-element/package.json +++ b/packages/active-element/package.json @@ -16,8 +16,7 @@ "name": "active-element", "stage": 3, "list": [ - "createActiveElement", - "createFocusSignal" + "createActiveElement" ], "category": "Inputs" }, @@ -55,10 +54,12 @@ "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.12", + "solid-js": "^2.0.0-beta.12" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.12", + "solid-js": "2.0.0-beta.12" } } diff --git a/packages/active-element/src/index.ts b/packages/active-element/src/index.ts index 307bcb7c3..4afc9b0d2 100644 --- a/packages/active-element/src/index.ts +++ b/packages/active-element/src/index.ts @@ -1,11 +1,7 @@ import { type Accessor, type JSX } from "solid-js"; -import { isServer } from "solid-js/web"; -import { - type MaybeAccessor, - type Directive, - createHydratableSignal, -} from "@solid-primitives/utils"; -import { makeEventListener, createEventListener } from "@solid-primitives/event-listener"; +import { isServer } from "@solidjs/web"; +import { type Directive, createHydratableSignal } from "@solid-primitives/utils"; +import { makeEventListener } from "@solid-primitives/event-listener"; declare module "solid-js" { namespace JSX { @@ -15,7 +11,7 @@ declare module "solid-js" { } } // This ensures the `JSX` import won't fall victim to tree shaking -export type E = JSX.Element; +export type E = JSX.Directives; const getActiveElement = () => document.activeElement === document.body ? null : document.activeElement; @@ -60,54 +56,6 @@ export function createActiveElement(): Accessor { return active; } -/** - * Attaches "blur" and "focus" event listeners to the element. - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/active-element#makeFocusListener - * @param target element - * @param callback handle focus change - * @param useCapture activates capturing, which allows to listen on events at the root that don't support bubbling. - * @returns function for clearing event listeners - * @example - * const [isFocused, setIsFocused] = createSignal(false) - * const clear = makeFocusListener(focused => setIsFocused(focused)); - * // remove listeners (happens also on cleanup) - * clear(); - */ -export function makeFocusListener( - target: Element, - callback: (isActive: boolean) => void, - useCapture = true, -): VoidFunction { - if (isServer) { - return () => void 0; - } - const clear1 = makeEventListener(target, "blur", callback.bind(void 0, false), useCapture); - const clear2 = makeEventListener(target, "focus", callback.bind(void 0, true), useCapture); - return () => (clear1(), clear2()); -} - -/** - * Provides a signal representing element's focus state. - * @param target element or a reactive function returning one - * @returns boolean signal representing element's focus state - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/active-element#createFocusSignal - * @example - * const isFocused = createFocusSignal(() => el) - * isFocused() // T: boolean - */ -export function createFocusSignal(target: MaybeAccessor): Accessor { - if (isServer) { - return () => false; - } - const [isActive, setIsActive] = createHydratableSignal( - false, - () => document.activeElement === target, - ); - createEventListener(target, "blur", () => setIsActive(false), true); - createEventListener(target, "focus", () => setIsActive(true), true); - return isActive; -} - /** * A directive that notifies you when the element becomes active or inactive. * @@ -123,5 +71,6 @@ export const focus: Directive<(isActive: boolean) => void> = (target, props) => } const callback = props(); callback(document.activeElement === target); - makeFocusListener(target, callback); + makeEventListener(target, "blur", callback.bind(void 0, false), true); + makeEventListener(target, "focus", callback.bind(void 0, true), true); }; diff --git a/packages/active-element/test/index.test.ts b/packages/active-element/test/index.test.ts index de3e574ac..a47b39bcc 100644 --- a/packages/active-element/test/index.test.ts +++ b/packages/active-element/test/index.test.ts @@ -1,12 +1,6 @@ import { createRoot } from "solid-js"; import { describe, test, expect } from "vitest"; -import { - makeActiveElementListener, - createActiveElement, - makeFocusListener, - createFocusSignal, - focus, -} from "../src/index.js"; +import { makeActiveElementListener, createActiveElement, focus } from "../src/index.js"; const dispatchFocusEvent = ( target: Element | Window = window, @@ -40,29 +34,6 @@ describe("makeActiveElementListener", () => { })); }); -describe("makeFocusListener", () => { - test("works properly", () => - createRoot(dispose => { - const el = document.createElement("div"); - const captured: any[] = []; - const clear = makeFocusListener(el, e => captured.push(e)); - expect(captured).toEqual([]); - dispatchFocusEvent(el, "focus"); - expect(captured).toEqual([true]); - dispatchFocusEvent(el, "blur"); - expect(captured).toEqual([true, false]); - clear(); - dispatchFocusEvent(el, "focus"); - expect(captured).toEqual([true, false]); - makeFocusListener(el, e => captured.push(e)); - dispatchFocusEvent(el, "blur"); - expect(captured).toEqual([true, false, false]); - dispose(); - dispatchFocusEvent(el, "focus"); - expect(captured).toEqual([true, false, false]); - })); -}); - describe("createActiveElement", () => { test("works properly", () => createRoot(dispose => { @@ -72,22 +43,6 @@ describe("createActiveElement", () => { })); }); -describe("createFocusSignal", () => { - test("works properly", () => - createRoot(dispose => { - const el = document.createElement("div"); - const activeEl = createFocusSignal(el); - expect(activeEl()).toBe(false); - dispatchFocusEvent(el, "focus"); - expect(activeEl()).toBe(true); - dispatchFocusEvent(el, "blur"); - expect(activeEl()).toBe(false); - dispose(); - dispatchFocusEvent(el, "focus"); - expect(activeEl()).toBe(false); - })); -}); - describe("use:focus", () => { test("works properly", () => createRoot(dispose => { diff --git a/packages/active-element/test/server.test.ts b/packages/active-element/test/server.test.ts index 830c9fb3d..558d9566d 100644 --- a/packages/active-element/test/server.test.ts +++ b/packages/active-element/test/server.test.ts @@ -1,8 +1,7 @@ import { describe, test, expect, vi } from "vitest"; -import { makeActiveElementListener, createActiveElement, createFocusSignal } from "../src/index.js"; +import { makeActiveElementListener, createActiveElement } from "../src/index.js"; describe("API doesn't break in SSR", () => { - // check if the API doesn't throw when calling it in SSR test("makeActiveElementListener() - SSR", () => { const cb = vi.fn(); expect(() => makeActiveElementListener(cb)).not.toThrow(); @@ -12,10 +11,4 @@ describe("API doesn't break in SSR", () => { test("createActiveElement() - SSR", () => { expect(() => createActiveElement()).not.toThrow(); }); - - test("createFocusSignal() - SSR", () => { - const el = vi.fn(); - expect(() => createFocusSignal(el)).not.toThrow(); - expect(el).not.toBeCalled(); - }); }); diff --git a/packages/autofocus/README.md b/packages/autofocus/README.md deleted file mode 100644 index d07bdfa56..000000000 --- a/packages/autofocus/README.md +++ /dev/null @@ -1,81 +0,0 @@ -

- Solid Primitives Autofocus -

- -# @solid-primitives/autofocus - -[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/autofocus?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/autofocus) -[![version](https://img.shields.io/npm/v/@solid-primitives/autofocus?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/autofocus) -[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) - -Primitives for autofocusing HTML elements. - -The native autofocus attribute only works on page load, which makes it incompatible with SolidJS. These primitives run on render, allowing autofocus on initial render as well as dynamically added components. - -- [`autofocus`](#autofocus) - Directive to autofocus an element on render. -- [`createAutofocus`](#createautofocus) - Reactive primitive to autofocus an element on render. - -## Installation - -```bash -npm install @solid-primitives/autofocus -# or -yarn add @solid-primitives/autofocus -# or -pnpm add @solid-primitives/autofocus -``` - -## `autofocus` - -### How to use it - -`autofocus` is a ref callback factory. It uses the native `autofocus` attribute to determine whether to focus the element. - -```tsx -import { autofocus } from "@solid-primitives/autofocus"; - -; -``` - -To conditionally enable autofocus, control the `autofocus` attribute directly — the `autofocus()` ref only focuses when the attribute is present, so removing it is sufficient to opt out: - -```tsx -// Conditionally autofocus by toggling the attribute -; -``` - -> **Note:** The `enabled` parameter was removed because it was redundant — the same effect is achieved by omitting the `autofocus` attribute. Previously, Solid directives always received an accessor argument whether you used it or not, which gave the impression an explicit toggle was necessary. - -### `createAutofocus` - -`createAutofocus` reactively autofocuses an element passed in as a signal. - -```tsx -import { createAutofocus } from "@solid-primitives/autofocus"; - -// Using ref -let ref!: HTMLButtonElement; -createAutofocus(() => ref); - -; - -// Using ref signal -const [ref, setRef] = createSignal(); -createAutofocus(ref); - -; -``` - -## Demo - -You may see the working example here: https://primitives.solidjs.community/playground/autofocus/ - -Source code: https://github.com/solidjs-community/solid-primitives/blob/main/packages/autofocus/dev/index.tsx - -## Changelog - -See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/autofocus/test/index.test.tsx b/packages/autofocus/test/index.test.tsx deleted file mode 100644 index d7bc0b901..000000000 --- a/packages/autofocus/test/index.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, test, expect, vi, beforeEach, afterAll, beforeAll } from "vitest"; -import { createRoot, createSignal, flush } from "solid-js"; -import { autofocus, createAutofocus } from "../src/index.js"; - -let focused: HTMLElement | null = null; - -const original_focus = HTMLElement.prototype.focus; -HTMLElement.prototype.focus = function (this) { - focused = this; -}; - -beforeAll(() => { - vi.useFakeTimers(); -}); - -beforeEach(() => { - vi.clearAllTimers(); - focused = null; -}); - -afterAll(() => { - vi.useRealTimers(); - HTMLElement.prototype.focus = original_focus; -}); - -describe("autofocus", () => { - test("focuses the element with autofocus attribute", () => { - const el = document.createElement("button"); - el.setAttribute("autofocus", ""); - - const dispose = createRoot(dispose => { - // Phase 1: factory registers onSettled - const ref = autofocus(); - // Phase 2: ref callback receives the element - ref(el); - return dispose; - }); - - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); - expect(focused).toBe(el); - - dispose(); - }); - - test("doesn't focus when autofocus HTML attribute is absent", () => { - const el = document.createElement("button"); - - const dispose = createRoot(dispose => { - const ref = autofocus(); - ref(el); - return dispose; - }); - - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); - expect(focused).toBe(null); - - dispose(); - }); - -}); - -describe("createAutofocus", () => { - const el = document.createElement("button"), - el2 = document.createElement("button"); - - test("createAutofocus focuses the element", () => { - const dispose = createRoot(dispose => { - createAutofocus(() => el); - return dispose; - }); - - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); - expect(focused).toBe(el); - - dispose(); - }); - - test("createAutofocus works with signal", () => { - const [ref, setRef] = createSignal(); - - const dispose = createRoot(dispose => { - createAutofocus(ref); - return dispose; - }); - - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); - expect(focused).toBe(null); - - setRef(el); - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); - expect(focused).toBe(el); - - setRef(el2); - flush(); - expect(focused).toBe(el); - vi.runAllTimers(); - expect(focused).toBe(el2); - - dispose(); - - setRef(el); - expect(focused).toBe(el2); - vi.runAllTimers(); - expect(focused).toBe(el2); - }); -}); diff --git a/packages/event-listener/src/types.ts b/packages/event-listener/src/types.ts index 3394558df..46794c15d 100644 --- a/packages/event-listener/src/types.ts +++ b/packages/event-listener/src/types.ts @@ -1,4 +1,4 @@ -import type { JSX } from "solid-js"; +import type { JSX } from "@solidjs/web"; export type EventListenerOptions = boolean | AddEventListenerOptions; diff --git a/packages/autofocus/CHANGELOG.md b/packages/focus/CHANGELOG.md similarity index 98% rename from packages/autofocus/CHANGELOG.md rename to packages/focus/CHANGELOG.md index 976ecae9d..ac009a623 100644 --- a/packages/autofocus/CHANGELOG.md +++ b/packages/focus/CHANGELOG.md @@ -1,4 +1,4 @@ -# @solid-primitives/autofocus +# @solid-primitives/focus ## 0.1.4 diff --git a/packages/autofocus/LICENSE b/packages/focus/LICENSE similarity index 84% rename from packages/autofocus/LICENSE rename to packages/focus/LICENSE index 38b41d975..7a35c2e14 100644 --- a/packages/autofocus/LICENSE +++ b/packages/focus/LICENSE @@ -2,6 +2,10 @@ MIT License Copyright (c) 2021 Solid Primitives Working Group +The `createFocusTrap` primitive is ported from solid-focus-trap: + Copyright (c) 2023 Jasmin Noetzli (GiyoMoon) + https://github.com/corvudev/corvu/tree/main/packages/solid-focus-trap + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights @@ -18,4 +22,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/packages/focus/README.md b/packages/focus/README.md new file mode 100644 index 000000000..92772a3e6 --- /dev/null +++ b/packages/focus/README.md @@ -0,0 +1,165 @@ +

+ Solid Primitives Focus +

+ +# @solid-primitives/focus + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/focus?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/focus) +[![version](https://img.shields.io/npm/v/@solid-primitives/focus?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/focus) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Primitives for autofocusing HTML elements and trapping focus within a container. + +The native `autofocus` attribute only works on page load, which makes it incompatible with SolidJS. These primitives run on render, allowing autofocus on initial render as well as dynamically added components. + +- [`autofocus`](#autofocus) - Ref callback factory to autofocus an element on render. +- [`createAutofocus`](#createautofocus) - Reactive primitive to autofocus an element on render. +- [`createFocusTrap`](#createfocustrap) - Traps focus inside a given DOM element. + +## Installation + +```bash +npm install @solid-primitives/focus +# or +yarn add @solid-primitives/focus +# or +pnpm add @solid-primitives/focus +``` + +## `autofocus` + +### How to use it + +`autofocus` is a ref callback factory. It uses the native `autofocus` attribute to determine whether to focus the element. + +```tsx +import { autofocus } from "@solid-primitives/focus"; + +; +``` + +To conditionally enable autofocus, control the `autofocus` attribute directly — the `autofocus()` ref only focuses when the attribute is present, so removing it is sufficient to opt out: + +```tsx +// Conditionally autofocus by toggling the attribute +; +``` + +> **Note:** The `enabled` parameter was removed because it was redundant — the same effect is achieved by omitting the `autofocus` attribute. Previously, Solid directives always received an accessor argument whether you used it or not, which gave the impression an explicit toggle was necessary. + +## `createAutofocus` + +`createAutofocus` reactively autofocuses an element passed in as a signal. + +```tsx +import { createAutofocus } from "@solid-primitives/focus"; + +// Using ref +let ref!: HTMLButtonElement; +createAutofocus(() => ref); + +; + +// Using ref signal +const [ref, setRef] = createSignal(); +createAutofocus(ref); + +; +``` + +## `createFocusTrap` + +`createFocusTrap` traps keyboard focus inside a given DOM element, cycling through focusable children on Tab / Shift+Tab. It uses a `MutationObserver` to stay up to date with DOM changes and restores focus to the previously focused element when deactivated. + +> Ported from [solid-focus-trap](https://github.com/corvudev/corvu/tree/main/packages/solid-focus-trap) by [Jasmin Noetzli (GiyoMoon)](https://github.com/GiyoMoon), adapted for Solid.js 2.0. + +### How to use it + +```tsx +import { createFocusTrap } from "@solid-primitives/focus"; + +const DialogContent: Component<{ open: boolean }> = props => { + const [contentRef, setContentRef] = createSignal(null); + + createFocusTrap({ + element: contentRef, + enabled: () => props.open, + }); + + return ( + +
+ + +
+
+ ); +}; +``` + +### Props + +| Prop | Type | Default | Description | +| -------------------- | --------------------------------- | -------------------------------- | --------------------------------------------------------------------------------- | +| `element` | `MaybeAccessor` | — | Element to trap focus within. | +| `enabled` | `MaybeAccessor` | `true` | Whether the trap is active. | +| `observeChanges` | `MaybeAccessor` | `true` | Watch for DOM mutations inside the container and refresh focusable elements. | +| `initialFocusElement`| `MaybeAccessor` | First focusable element | Element to focus when the trap activates. | +| `restoreFocus` | `MaybeAccessor` | `true` | Restore focus to the previously focused element when the trap deactivates. | +| `finalFocusElement` | `MaybeAccessor` | Previously focused element | Element to focus when the trap deactivates. | +| `onInitialFocus` | `(event: Event) => void` | — | Callback when focus moves into the trap. Call `event.preventDefault()` to cancel.| +| `onFinalFocus` | `(event: Event) => void` | — | Callback when focus restores. Call `event.preventDefault()` to cancel. | + +### Custom initial focus + +```tsx +const [contentRef, setContentRef] = createSignal(null); +const [inputRef, setInputRef] = createSignal(null); + +createFocusTrap({ + element: contentRef, + enabled: () => props.open, + initialFocusElement: inputRef, +}); + +return ( + +
+ + +
+
+); +``` + +### Preventing focus moves + +```tsx +createFocusTrap({ + element: contentRef, + onInitialFocus: event => { + event.preventDefault(); // focus won't move on activation + }, + onFinalFocus: event => { + event.preventDefault(); // focus won't restore on deactivation + }, +}); +``` + +## Demo + +You may see the working example here: https://primitives.solidjs.community/playground/focus/ + +Source code: https://github.com/solidjs-community/solid-primitives/blob/main/packages/focus/dev/index.tsx + +## Credits + +`createFocusTrap` is ported from [solid-focus-trap](https://github.com/corvudev/corvu/tree/main/packages/solid-focus-trap), part of the [corvu](https://corvu.dev) UI toolkit by [Jasmin Noetzli (GiyoMoon)](https://github.com/GiyoMoon). Licensed under the MIT License. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/autofocus/dev/index.tsx b/packages/focus/dev/index.tsx similarity index 100% rename from packages/autofocus/dev/index.tsx rename to packages/focus/dev/index.tsx diff --git a/packages/autofocus/package.json b/packages/focus/package.json similarity index 68% rename from packages/autofocus/package.json rename to packages/focus/package.json index c0e0ca5af..c848eaa19 100644 --- a/packages/autofocus/package.json +++ b/packages/focus/package.json @@ -1,11 +1,20 @@ { - "name": "@solid-primitives/autofocus", + "name": "@solid-primitives/focus", "version": "0.2.0", - "description": "Primitives for autofocusing HTML elements", + "description": "Primitives for autofocusing HTML elements and trapping focus within a container", "author": "jer3m01 ", - "contributors": [], + "contributors": [ + { + "name": "Jasmin Noetzli", + "url": "https://github.com/GiyoMoon" + }, + { + "name": "David Di Biase", + "url": "https://github.com/davedbase" + } + ], "license": "MIT", - "homepage": "https://primitives.solidjs.community/package/autofocus", + "homepage": "https://primitives.solidjs.community/package/focus", "repository": { "type": "git", "url": "git+https://github.com/solidjs-community/solid-primitives.git" @@ -14,11 +23,14 @@ "url": "https://github.com/solidjs-community/solid-primitives/issues" }, "primitive": { - "name": "autofocus", + "name": "focus", "stage": 1, "list": [ "autofocus", - "createAutofocus" + "createAutofocus", + "createFocusTrap", + "makeFocusListener", + "createFocusSignal" ], "category": "Inputs" }, @@ -26,7 +38,11 @@ "solid", "primitives", "focus", - "autofocus" + "autofocus", + "focus-trap", + "trap", + "accessibility", + "a11y" ], "private": false, "sideEffects": false, @@ -56,6 +72,7 @@ "solid-js": "^2.0.0-beta.12" }, "dependencies": { + "@solid-primitives/event-listener": "workspace:^", "@solid-primitives/utils": "workspace:^" }, "typesVersions": {}, diff --git a/packages/autofocus/src/index.ts b/packages/focus/src/autofocus.ts similarity index 100% rename from packages/autofocus/src/index.ts rename to packages/focus/src/autofocus.ts diff --git a/packages/focus/src/focusSignal.ts b/packages/focus/src/focusSignal.ts new file mode 100644 index 000000000..c24e09e6c --- /dev/null +++ b/packages/focus/src/focusSignal.ts @@ -0,0 +1,52 @@ +import { type Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { type MaybeAccessor, createHydratableSignal } from "@solid-primitives/utils"; +import { makeEventListener, createEventListener } from "@solid-primitives/event-listener"; + +/** + * Attaches "blur" and "focus" event listeners to the element. + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/focus#makeFocusListener + * @param target element + * @param callback handle focus change + * @param useCapture activates capturing, which allows to listen on events at the root that don't support bubbling. + * @returns function for clearing event listeners + * @example + * const [isFocused, setIsFocused] = createSignal(false) + * const clear = makeFocusListener(el, focused => setIsFocused(focused)); + * // remove listeners (happens also on cleanup) + * clear(); + */ +export function makeFocusListener( + target: Element, + callback: (isActive: boolean) => void, + useCapture = true, +): VoidFunction { + if (isServer) { + return () => {}; + } + const clear1 = makeEventListener(target, "blur", callback.bind(undefined, false), useCapture); + const clear2 = makeEventListener(target, "focus", callback.bind(undefined, true), useCapture); + return () => (clear1(), clear2()); +} + +/** + * Provides a signal representing element's focus state. + * @param target element or a reactive function returning one + * @returns boolean signal representing element's focus state + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/focus#createFocusSignal + * @example + * const isFocused = createFocusSignal(() => el) + * isFocused() // T: boolean + */ +export function createFocusSignal(target: MaybeAccessor): Accessor { + if (isServer) { + return () => false; + } + const [isActive, setIsActive] = createHydratableSignal( + false, + () => document.activeElement === target, + ); + createEventListener(target, "blur", () => setIsActive(false), true); + createEventListener(target, "focus", () => setIsActive(true), true); + return isActive; +} diff --git a/packages/focus/src/focusTrap.ts b/packages/focus/src/focusTrap.ts new file mode 100644 index 000000000..06391279e --- /dev/null +++ b/packages/focus/src/focusTrap.ts @@ -0,0 +1,214 @@ +/* + * Ported from solid-focus-trap by Jasmin Noetzli (GiyoMoon) + * MIT License — https://github.com/corvudev/corvu/tree/main/packages/solid-focus-trap + * Adapted for Solid.js 2.0 and @solid-primitives/focus by the Solid Primitives Working Group. + */ + +import { access, afterPaint, INTERNAL_OPTIONS, type MaybeAccessor } from "@solid-primitives/utils"; +import { createEffect, createMemo, createSignal } from "solid-js"; + +const FOCUSABLE_SELECTOR = + 'a[href]:not([tabindex="-1"]), button:not([tabindex="-1"]), input:not([tabindex="-1"]), textarea:not([tabindex="-1"]), select:not([tabindex="-1"]), details:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'; + +const EVENT_INITIAL_FOCUS = "focusTrap.initialFocus"; +const EVENT_FINAL_FOCUS = "focusTrap.finalFocus"; +const EVENT_OPTIONS = { bubbles: false, cancelable: true } as const; + +export type CreateFocusTrapProps = { + /** Element to trap focus within. */ + element: MaybeAccessor; + /** Whether the focus trap is active. Default: `true` */ + enabled?: MaybeAccessor; + /** + * Watch for DOM mutations inside the container and reload the list of + * focusable elements accordingly. Default: `true` + */ + observeChanges?: MaybeAccessor; + /** + * Element to focus when the trap activates. + * Default: the first focusable element inside `element`. + */ + initialFocusElement?: MaybeAccessor; + /** + * Restore focus to the element that was focused before the trap activated + * when the trap is deactivated. Default: `true` + */ + restoreFocus?: MaybeAccessor; + /** + * Element to focus when the trap deactivates. + * Default: the element that was focused before the trap activated. + */ + finalFocusElement?: MaybeAccessor; + /** + * Callback fired when focus moves into the trap. + * Call `event.preventDefault()` to suppress the focus move. + */ + onInitialFocus?: (event: Event) => void; + /** + * Callback fired when focus is restored after deactivation. + * Call `event.preventDefault()` to suppress the focus move. + */ + onFinalFocus?: (event: Event) => void; +}; + +/** + * Traps focus inside the given element. Aware of DOM changes inside the trap + * via a MutationObserver. Properly restores focus when deactivated. + * + * Ported from [solid-focus-trap](https://github.com/corvudev/corvu/tree/main/packages/solid-focus-trap) + * by Jasmin Noetzli (GiyoMoon), adapted for Solid.js 2.0. + * + * @example + * ```tsx + * const [ref, setRef] = createSignal(null); + * createFocusTrap({ element: ref, enabled: () => isOpen() }); + *
...
+ * ``` + */ +export const createFocusTrap = (props: CreateFocusTrapProps): void => { + const [focusableElements, setFocusableElements] = createSignal( + undefined, + INTERNAL_OPTIONS, + ); + + const firstFocusElement = createMemo(() => { + const els = focusableElements(); + return els ? (els[0] ?? null) : null; + }); + + const lastFocusElement = createMemo(() => { + const els = focusableElements(); + return els ? (els[els.length - 1] ?? null) : null; + }); + + let originalFocusedElement: HTMLElement | null = null; + + const loadFocusableElements = (container: HTMLElement) => { + const sorted = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)) + .map((element, domIndex) => ({ element, domIndex, tabIndex: element.tabIndex })) + .sort((a, b) => + a.tabIndex === b.tabIndex ? a.domIndex - b.domIndex : a.tabIndex - b.tabIndex, + ); + setFocusableElements(sorted.map(({ element }) => element)); + }; + + const triggerInitialFocus = (container: HTMLElement) => { + afterPaint(() => { + const target = access(props.initialFocusElement ?? null) ?? firstFocusElement() ?? container; + const { onInitialFocus } = props; + if (onInitialFocus) { + const event = new CustomEvent(EVENT_INITIAL_FOCUS, EVENT_OPTIONS); + container.addEventListener(EVENT_INITIAL_FOCUS, onInitialFocus); + container.dispatchEvent(event); + container.removeEventListener(EVENT_INITIAL_FOCUS, onInitialFocus); + if (event.defaultPrevented) return; + } + target.focus(); + }); + }; + + const triggerRestoreFocus = (container: HTMLElement) => { + afterPaint(() => { + if (!access(props.restoreFocus ?? true)) return; + const target = access(props.finalFocusElement ?? null) ?? originalFocusedElement; + if (!target) return; + const { onFinalFocus } = props; + if (onFinalFocus) { + const event = new CustomEvent(EVENT_FINAL_FOCUS, EVENT_OPTIONS); + container.addEventListener(EVENT_FINAL_FOCUS, onFinalFocus); + container.dispatchEvent(event); + container.removeEventListener(EVENT_FINAL_FOCUS, onFinalFocus); + if (event.defaultPrevented) return; + } + target.focus(); + }); + }; + + const onFirstElementKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab" && event.shiftKey) { + event.preventDefault(); + lastFocusElement()!.focus(); + } + }; + + const onLastElementKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab" && !event.shiftKey) { + event.preventDefault(); + firstFocusElement()!.focus(); + } + }; + + const preventTab = (event: KeyboardEvent) => { + if (event.key === "Tab") event.preventDefault(); + }; + + // Activate / deactivate the trap when element or enabled changes. + createEffect( + () => ({ + container: access(props.element), + enabled: access(props.enabled ?? true), + observeChanges: access(props.observeChanges ?? true), + }), + ({ container, enabled, observeChanges }) => { + if (!container || !enabled) return; + + originalFocusedElement = document.activeElement as HTMLElement | null; + loadFocusableElements(container); + triggerInitialFocus(container); + + const observer = new MutationObserver(() => { + afterPaint(() => { + loadFocusableElements(container); + if (!document.activeElement || document.activeElement === document.body) { + triggerInitialFocus(container); + } + }); + }); + + if (observeChanges) { + observer.observe(container, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["tabindex"], + }); + } + + return () => { + if (observeChanges) observer.disconnect(); + setFocusableElements(undefined); + triggerRestoreFocus(container); + }; + }, + ); + + // When there are no focusable elements, block all Tab key presses. + createEffect( + () => focusableElements(), + elements => { + if (!elements || elements.length !== 0) return; + document.addEventListener("keydown", preventTab); + return () => document.removeEventListener("keydown", preventTab); + }, + ); + + // Shift+Tab on the first element → wrap to last. + createEffect( + () => firstFocusElement(), + el => { + if (!el) return; + el.addEventListener("keydown", onFirstElementKeyDown); + return () => el.removeEventListener("keydown", onFirstElementKeyDown); + }, + ); + + // Tab on the last element → wrap to first. + createEffect( + () => lastFocusElement(), + el => { + if (!el) return; + el.addEventListener("keydown", onLastElementKeyDown); + return () => el.removeEventListener("keydown", onLastElementKeyDown); + }, + ); +}; diff --git a/packages/focus/src/index.ts b/packages/focus/src/index.ts new file mode 100644 index 000000000..678243b0b --- /dev/null +++ b/packages/focus/src/index.ts @@ -0,0 +1,5 @@ +export { autofocus, createAutofocus } from "./autofocus.js"; +export type { E } from "./autofocus.js"; +export { createFocusTrap } from "./focusTrap.js"; +export type { CreateFocusTrapProps } from "./focusTrap.js"; +export { makeFocusListener, createFocusSignal } from "./focusSignal.js"; diff --git a/packages/focus/test/index.test.tsx b/packages/focus/test/index.test.tsx new file mode 100644 index 000000000..d1c8aa578 --- /dev/null +++ b/packages/focus/test/index.test.tsx @@ -0,0 +1,438 @@ +import { describe, test, expect, vi, beforeEach, afterAll, beforeAll } from "vitest"; +import { createRoot, createSignal, flush } from "solid-js"; +import { autofocus, createAutofocus, createFocusTrap } from "../src/index.js"; + +// ─── Shared focus tracking ──────────────────────────────────────────────────── + +let focused: HTMLElement | null = null; + +const original_focus = HTMLElement.prototype.focus; +HTMLElement.prototype.focus = function (this: HTMLElement) { + focused = this; +}; + +// ─── Fake timers + rAF stub ─────────────────────────────────────────────────── + +beforeAll(() => { + vi.useFakeTimers(); + // afterPaint uses double rAF; stub it as setTimeout so vi.runAllTimers() drives it. + vi.stubGlobal("requestAnimationFrame", (fn: FrameRequestCallback) => + setTimeout(() => fn(performance.now()), 0), + ); + vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id)); +}); + +beforeEach(() => { + vi.clearAllTimers(); + focused = null; +}); + +afterAll(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + HTMLElement.prototype.focus = original_focus; +}); + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +/** Run all pending effects then drain all timers (including nested rAFs). */ +const settle = () => { + flush(); + vi.runAllTimers(); +}; + +// ─── autofocus ──────────────────────────────────────────────────────────────── + +describe("autofocus", () => { + test("focuses the element with autofocus attribute", () => { + const el = document.createElement("button"); + el.setAttribute("autofocus", ""); + + const dispose = createRoot(dispose => { + const ref = autofocus(); + ref(el); + return dispose; + }); + + settle(); + expect(focused).toBe(el); + dispose(); + }); + + test("doesn't focus when autofocus attribute is absent", () => { + const el = document.createElement("button"); + + const dispose = createRoot(dispose => { + const ref = autofocus(); + ref(el); + return dispose; + }); + + settle(); + expect(focused).toBe(null); + dispose(); + }); +}); + +// ─── createAutofocus ────────────────────────────────────────────────────────── + +describe("createAutofocus", () => { + const el = document.createElement("button"), + el2 = document.createElement("button"); + + test("focuses the element", () => { + const dispose = createRoot(dispose => { + createAutofocus(() => el); + return dispose; + }); + + settle(); + expect(focused).toBe(el); + dispose(); + }); + + test("works with signal — focuses when signal is set", () => { + const [ref, setRef] = createSignal(); + + const dispose = createRoot(dispose => { + createAutofocus(ref); + return dispose; + }); + + settle(); + expect(focused).toBe(null); + + setRef(el); + settle(); + expect(focused).toBe(el); + + setRef(el2); + settle(); + expect(focused).toBe(el2); + + dispose(); + + setRef(el); + vi.runAllTimers(); + expect(focused).toBe(el2); // no focus after dispose + }); +}); + +// ─── createFocusTrap ────────────────────────────────────────────────────────── + +/** Build a container with `n` focusable buttons and return them. */ +function makeContainer(n: number): { container: HTMLElement; buttons: HTMLButtonElement[] } { + const container = document.createElement("div"); + const buttons: HTMLButtonElement[] = []; + for (let i = 0; i < n; i++) { + const btn = document.createElement("button"); + btn.textContent = `btn${i}`; + container.appendChild(btn); + buttons.push(btn); + } + return { container, buttons }; +} + +function tabKey(shiftKey = false) { + return new KeyboardEvent("keydown", { key: "Tab", shiftKey, bubbles: true, cancelable: true }); +} + +describe("createFocusTrap", () => { + test("focuses the first focusable element on activation", () => { + const { container, buttons } = makeContainer(3); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container }); + return dispose; + }); + + settle(); + expect(focused).toBe(buttons[0]); + dispose(); + }); + + test("Tab on last element wraps to first", () => { + const { container, buttons } = makeContainer(3); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container }); + return dispose; + }); + + settle(); + buttons[2]!.dispatchEvent(tabKey(false)); + expect(focused).toBe(buttons[0]); + dispose(); + }); + + test("Shift+Tab on first element wraps to last", () => { + const { container, buttons } = makeContainer(3); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container }); + return dispose; + }); + + settle(); + buttons[0]!.dispatchEvent(tabKey(true)); + expect(focused).toBe(buttons[2]); + dispose(); + }); + + test("blocks Tab when there are no focusable elements", () => { + const container = document.createElement("div"); // no children + + let tabPrevented = false; + const dispose = createRoot(dispose => { + createFocusTrap({ element: container }); + return dispose; + }); + + flush(); // run effects so preventTab listener is added + + const event = tabKey(); + Object.defineProperty(event, "defaultPrevented", { get: () => tabPrevented }); + const originalPreventDefault = event.preventDefault.bind(event); + event.preventDefault = () => { + tabPrevented = true; + originalPreventDefault(); + }; + + document.dispatchEvent(event); + expect(tabPrevented).toBe(true); + dispose(); + }); + + test("does not activate when enabled is false", () => { + const { container } = makeContainer(2); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, enabled: false }); + return dispose; + }); + + settle(); + expect(focused).toBe(null); + dispose(); + }); + + test("activates and deactivates reactively via enabled signal", () => { + const { container, buttons } = makeContainer(2); + const [enabled, setEnabled] = createSignal(false); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, enabled }); + return dispose; + }); + + settle(); + expect(focused).toBe(null); // not yet enabled + + setEnabled(true); + settle(); + expect(focused).toBe(buttons[0]); // initial focus + + dispose(); + }); + + test("restores focus to the previously focused element on deactivation", () => { + const { container, buttons } = makeContainer(2); + const trigger = document.createElement("button"); + const [enabled, setEnabled] = createSignal(true); + + // Pretend `trigger` is the element that was focused before the trap. + const origActiveElement = Object.getOwnPropertyDescriptor(Document.prototype, "activeElement")!; + Object.defineProperty(document, "activeElement", { + get: () => trigger, + configurable: true, + }); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, enabled }); + return dispose; + }); + + settle(); + expect(focused).toBe(buttons[0]); // initial focus inside trap + + // Restore the real activeElement descriptor before deactivating + Object.defineProperty(document, "activeElement", origActiveElement); + + setEnabled(false); + settle(); + expect(focused).toBe(trigger); // focus restored + dispose(); + }); + + test("uses initialFocusElement when provided", () => { + const { container, buttons } = makeContainer(3); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, initialFocusElement: buttons[2] }); + return dispose; + }); + + settle(); + expect(focused).toBe(buttons[2]); + dispose(); + }); + + test("uses finalFocusElement when provided on deactivation", () => { + const { container } = makeContainer(2); + const customFinal = document.createElement("button"); + const [enabled, setEnabled] = createSignal(true); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, enabled, finalFocusElement: customFinal }); + return dispose; + }); + + settle(); + + setEnabled(false); + settle(); + expect(focused).toBe(customFinal); + dispose(); + }); + + test("onInitialFocus callback is called when trap activates", () => { + const { container } = makeContainer(1); + const onInitialFocus = vi.fn(); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, onInitialFocus }); + return dispose; + }); + + settle(); + expect(onInitialFocus).toHaveBeenCalledOnce(); + dispose(); + }); + + test("onInitialFocus preventDefault suppresses initial focus", () => { + const { container } = makeContainer(1); + + const dispose = createRoot(dispose => { + createFocusTrap({ + element: container, + onInitialFocus: e => e.preventDefault(), + }); + return dispose; + }); + + settle(); + expect(focused).toBe(null); + dispose(); + }); + + test("onFinalFocus callback is called on deactivation", () => { + const { container } = makeContainer(1); + const [enabled, setEnabled] = createSignal(true); + const onFinalFocus = vi.fn(); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, enabled, onFinalFocus }); + return dispose; + }); + + settle(); + setEnabled(false); + settle(); + expect(onFinalFocus).toHaveBeenCalledOnce(); + dispose(); + }); + + test("onFinalFocus preventDefault suppresses focus restore", () => { + const { container } = makeContainer(1); + const trigger = document.createElement("button"); + const [enabled, setEnabled] = createSignal(true); + + const origActiveElement = Object.getOwnPropertyDescriptor(Document.prototype, "activeElement")!; + Object.defineProperty(document, "activeElement", { + get: () => trigger, + configurable: true, + }); + + const dispose = createRoot(dispose => { + createFocusTrap({ + element: container, + enabled, + onFinalFocus: e => e.preventDefault(), + }); + return dispose; + }); + + settle(); + Object.defineProperty(document, "activeElement", origActiveElement); + + focused = null; + setEnabled(false); + settle(); + expect(focused).toBe(null); // prevented + dispose(); + }); + + test("does not restore focus when restoreFocus is false", () => { + const { container } = makeContainer(1); + const [enabled, setEnabled] = createSignal(true); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container, enabled, restoreFocus: false }); + return dispose; + }); + + settle(); + focused = null; + setEnabled(false); + settle(); + expect(focused).toBe(null); + dispose(); + }); + + test("respects tabIndex ordering for focusable elements", () => { + const container = document.createElement("div"); + const a = document.createElement("button"); // tabIndex 0 + const b = document.createElement("button"); + b.tabIndex = 2; + const c = document.createElement("button"); + c.tabIndex = 1; + // DOM order: a(0), b(2), c(1) → sorted: a(0), c(1), b(2) + container.append(a, b, c); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: container }); + return dispose; + }); + + settle(); + expect(focused).toBe(a); // first by tabIndex order + + // Tab on last (b, tabIndex=2) wraps to first (a, tabIndex=0) + b.dispatchEvent(tabKey(false)); + expect(focused).toBe(a); + + // Shift+Tab on first (a) wraps to last (b) + a.dispatchEvent(tabKey(true)); + expect(focused).toBe(b); + + dispose(); + }); + + test("element as reactive signal — activates when signal becomes non-null", () => { + const { container, buttons } = makeContainer(2); + const [el, setEl] = createSignal(null); + + const dispose = createRoot(dispose => { + createFocusTrap({ element: el }); + return dispose; + }); + + settle(); + expect(focused).toBe(null); + + setEl(container); + settle(); + expect(focused).toBe(buttons[0]); + dispose(); + }); +}); diff --git a/packages/autofocus/test/server.test.ts b/packages/focus/test/server.test.ts similarity index 55% rename from packages/autofocus/test/server.test.ts rename to packages/focus/test/server.test.ts index d8c2b5f8b..9b4ae4800 100644 --- a/packages/autofocus/test/server.test.ts +++ b/packages/focus/test/server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createRoot } from "solid-js"; -import { createAutofocus } from "../src/index.js"; +import { createAutofocus, createFocusTrap } from "../src/index.js"; describe("API doesn't break in SSR", () => { it("createAutofocus() - SSR", () => { @@ -9,4 +9,11 @@ describe("API doesn't break in SSR", () => { dispose(); }); }); + + it("createFocusTrap() - SSR", () => { + createRoot(dispose => { + expect(() => createFocusTrap({ element: null })).not.toThrow(); + dispose(); + }); + }); }); diff --git a/packages/autofocus/tsconfig.json b/packages/focus/tsconfig.json similarity index 82% rename from packages/autofocus/tsconfig.json rename to packages/focus/tsconfig.json index dc1970e16..b9b2b6782 100644 --- a/packages/autofocus/tsconfig.json +++ b/packages/focus/tsconfig.json @@ -6,6 +6,9 @@ "rootDir": "src" }, "references": [ + { + "path": "../event-listener" + }, { "path": "../utils" } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index df4a9c2a6..8213e539d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,11 +1,11 @@ import { getOwner, onCleanup, + onSettled, createSignal, createStore, type Accessor, untrack, - type AccessorArray, type EffectFunction, type ComputeFunction, type NoInfer, @@ -15,10 +15,11 @@ import { type Store, type StoreSetter, sharedConfig, - onMount, DEV, - equalFn, + isEqual, } from "solid-js"; + +type AccessorArray = Accessor[]; // isServer moved from solid-js/web (1.x) to @solidjs/web (2.x). // typeof window is a universal fallback compatible with both versions. const isServer = typeof window === "undefined"; @@ -43,15 +44,15 @@ export const isDev = isClient && !!DEV; export const isProd = !isDev; /** no operation */ -export const noop = (() => void 0) as Noop; +export const noop = (() => {}) as Noop; export const trueFn: () => boolean = () => true; export const falseFn: () => boolean = () => false; -/** @deprecated use {@link equalFn} from "solid-js" */ -export const defaultEquals = equalFn; +/** @deprecated use {@link isEqual} from "solid-js" */ +export const defaultEquals = isEqual; export const EQUALS_FALSE_OPTIONS = { equals: false } as const satisfies SignalOptions; -export const INTERNAL_OPTIONS = { internal: true } as const satisfies SignalOptions; +export const INTERNAL_OPTIONS = {} as const satisfies SignalOptions; /** * Check if the value is an instance of ___ @@ -320,6 +321,16 @@ export function handleDiffArray( } } +/** + * Schedules `fn` to run after the browser has painted by nesting two + * requestAnimationFrame calls. No-op in non-browser environments. + */ +export const afterPaint = (fn: () => void): void => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => requestAnimationFrame(fn)); + } +}; + // ─── String transforms ──────────────────────────────────────────────────────── /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 696c52a39..17e225781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,9 +102,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.12 + version: 2.0.0-beta.12(solid-js@2.0.0-beta.12) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.12 + version: 2.0.0-beta.12 packages/analytics: devDependencies: @@ -125,19 +128,6 @@ importers: specifier: ^1.9.7 version: 1.9.7 - packages/autofocus: - dependencies: - '@solid-primitives/utils': - specifier: workspace:^ - version: link:../utils - devDependencies: - '@solidjs/web': - specifier: 2.0.0-beta.12 - version: 2.0.0-beta.12(solid-js@2.0.0-beta.12) - solid-js: - specifier: 2.0.0-beta.12 - version: 2.0.0-beta.12 - packages/bounds: dependencies: '@solid-primitives/event-listener': @@ -354,6 +344,22 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/focus: + dependencies: + '@solid-primitives/event-listener': + specifier: workspace:^ + version: link:../event-listener + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.12 + version: 2.0.0-beta.12(solid-js@2.0.0-beta.12) + solid-js: + specifier: 2.0.0-beta.12 + version: 2.0.0-beta.12 + packages/fullscreen: dependencies: '@solid-primitives/utils':