From ddf4ecd5b2e942227265a1bc1379f34512ab27d1 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Wed, 13 May 2026 09:49:47 -0400 Subject: [PATCH 1/7] Rename to focus package --- ...migration.md => focus-solid2-migration.md} | 2 +- .changeset/pre.json | 2 +- README.md | 2 +- packages/{autofocus => focus}/CHANGELOG.md | 2 +- packages/{autofocus => focus}/LICENSE | 0 packages/{autofocus => focus}/README.md | 22 ++++++++-------- packages/{autofocus => focus}/dev/index.tsx | 0 packages/{autofocus => focus}/package.json | 6 ++--- packages/{autofocus => focus}/src/index.ts | 0 .../{autofocus => focus}/test/index.test.tsx | 0 .../{autofocus => focus}/test/server.test.ts | 0 packages/{autofocus => focus}/tsconfig.json | 0 pnpm-lock.yaml | 26 +++++++++---------- 13 files changed, 31 insertions(+), 31 deletions(-) rename .changeset/{autofocus-solid2-migration.md => focus-solid2-migration.md} (95%) rename packages/{autofocus => focus}/CHANGELOG.md (98%) rename packages/{autofocus => focus}/LICENSE (100%) rename packages/{autofocus => focus}/README.md (77%) rename packages/{autofocus => focus}/dev/index.tsx (100%) rename packages/{autofocus => focus}/package.json (92%) rename packages/{autofocus => focus}/src/index.ts (100%) rename packages/{autofocus => focus}/test/index.test.tsx (100%) rename packages/{autofocus => focus}/test/server.test.ts (100%) rename packages/{autofocus => focus}/tsconfig.json (100%) diff --git a/.changeset/autofocus-solid2-migration.md b/.changeset/focus-solid2-migration.md similarity index 95% rename from .changeset/autofocus-solid2-migration.md rename to .changeset/focus-solid2-migration.md index a9c43b560..8720ad291 100644 --- a/.changeset/autofocus-solid2-migration.md +++ b/.changeset/focus-solid2-migration.md @@ -1,5 +1,5 @@ --- -"@solid-primitives/autofocus": major +"@solid-primitives/focus": major --- Migrate to Solid.js v2.0 (beta.10) 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/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 100% rename from packages/autofocus/LICENSE rename to packages/focus/LICENSE diff --git a/packages/autofocus/README.md b/packages/focus/README.md similarity index 77% rename from packages/autofocus/README.md rename to packages/focus/README.md index d07bdfa56..7d3b64079 100644 --- a/packages/autofocus/README.md +++ b/packages/focus/README.md @@ -1,11 +1,11 @@

- Solid Primitives Autofocus + Solid Primitives Focus

-# @solid-primitives/autofocus +# @solid-primitives/focus -[![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) +[![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. @@ -18,11 +18,11 @@ The native autofocus attribute only works on page load, which makes it incompati ## Installation ```bash -npm install @solid-primitives/autofocus +npm install @solid-primitives/focus # or -yarn add @solid-primitives/autofocus +yarn add @solid-primitives/focus # or -pnpm add @solid-primitives/autofocus +pnpm add @solid-primitives/focus ``` ## `autofocus` @@ -32,7 +32,7 @@ pnpm add @solid-primitives/autofocus `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"; +import { autofocus } from "@solid-primitives/focus"; ; ``` +## `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/focus/package.json b/packages/focus/package.json index 27f0b8602..0c1539fae 100644 --- a/packages/focus/package.json +++ b/packages/focus/package.json @@ -1,9 +1,14 @@ { "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" + } + ], "license": "MIT", "homepage": "https://primitives.solidjs.community/package/focus", "repository": { @@ -18,7 +23,8 @@ "stage": 1, "list": [ "autofocus", - "createAutofocus" + "createAutofocus", + "createFocusTrap" ], "category": "Inputs" }, @@ -26,7 +32,11 @@ "solid", "primitives", "focus", - "autofocus" + "autofocus", + "focus-trap", + "trap", + "accessibility", + "a11y" ], "private": false, "sideEffects": false, diff --git a/packages/focus/src/autofocus.ts b/packages/focus/src/autofocus.ts new file mode 100644 index 000000000..5a5cf0e06 --- /dev/null +++ b/packages/focus/src/autofocus.ts @@ -0,0 +1,65 @@ +import { createEffect, onSettled, type Accessor } from "solid-js"; +import type { JSX } from "@solidjs/web"; +import { type FalsyValue } from "@solid-primitives/utils"; + +/** + * Ref callback factory to autofocus an element on render. + * Uses the native `autofocus` attribute to determine whether to focus. + * + * To disable autofocus, simply omit the `autofocus` attribute on the element — + * no `enabled` parameter is needed or provided. + * + * @returns Ref callback to attach to the element. + * + * @example + * ```tsx + * + * ``` + */ +export const autofocus = () => { + let el: HTMLElement | undefined; + + onSettled(() => { + if (!el?.hasAttribute("autofocus")) return; + const id = setTimeout(() => el?.focus()); + return () => clearTimeout(id); + }); + + return (element: HTMLElement) => { + el = element; + }; +}; + +/** + * Creates a new reactive primitive for autofocusing the element on render. + * + * @param ref - Element to focus. + * + * @example + * ```ts + * let ref!: HTMLButtonElement; + * + * createAutofocus(() => ref); + * + * ; + * + * // Using ref signal + * const [ref, setRef] = createSignal(); + * createAutofocus(ref); + * + * ; + * ``` + */ +export const createAutofocus = (ref: Accessor) => { + createEffect( + () => ref(), + el => { + if (!el) return; + const id = setTimeout(() => el.focus()); + return () => clearTimeout(id); + }, + ); +}; + +// only here so the `JSX` import won't be shaken off the tree: +export type E = JSX.Element; diff --git a/packages/focus/src/focusTrap.ts b/packages/focus/src/focusTrap.ts new file mode 100644 index 000000000..669b0312b --- /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( + null, + 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(null); + triggerRestoreFocus(container); + }; + }, + ); + + // When there are no focusable elements, block all Tab key presses. + createEffect( + () => focusableElements(), + elements => { + if (elements === null || 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 index 5a5cf0e06..bc812c568 100644 --- a/packages/focus/src/index.ts +++ b/packages/focus/src/index.ts @@ -1,65 +1,4 @@ -import { createEffect, onSettled, type Accessor } from "solid-js"; -import type { JSX } from "@solidjs/web"; -import { type FalsyValue } from "@solid-primitives/utils"; - -/** - * Ref callback factory to autofocus an element on render. - * Uses the native `autofocus` attribute to determine whether to focus. - * - * To disable autofocus, simply omit the `autofocus` attribute on the element — - * no `enabled` parameter is needed or provided. - * - * @returns Ref callback to attach to the element. - * - * @example - * ```tsx - * - * ``` - */ -export const autofocus = () => { - let el: HTMLElement | undefined; - - onSettled(() => { - if (!el?.hasAttribute("autofocus")) return; - const id = setTimeout(() => el?.focus()); - return () => clearTimeout(id); - }); - - return (element: HTMLElement) => { - el = element; - }; -}; - -/** - * Creates a new reactive primitive for autofocusing the element on render. - * - * @param ref - Element to focus. - * - * @example - * ```ts - * let ref!: HTMLButtonElement; - * - * createAutofocus(() => ref); - * - * ; - * - * // Using ref signal - * const [ref, setRef] = createSignal(); - * createAutofocus(ref); - * - * ; - * ``` - */ -export const createAutofocus = (ref: Accessor) => { - createEffect( - () => ref(), - el => { - if (!el) return; - const id = setTimeout(() => el.focus()); - return () => clearTimeout(id); - }, - ); -}; - -// only here so the `JSX` import won't be shaken off the tree: -export type E = JSX.Element; +export { autofocus, createAutofocus } from "./autofocus.js"; +export type { E } from "./autofocus.js"; +export { createFocusTrap } from "./focusTrap.js"; +export type { CreateFocusTrapProps } from "./focusTrap.js"; diff --git a/packages/focus/test/index.test.tsx b/packages/focus/test/index.test.tsx index d7bc0b901..d1c8aa578 100644 --- a/packages/focus/test/index.test.tsx +++ b/packages/focus/test/index.test.tsx @@ -1,16 +1,25 @@ import { describe, test, expect, vi, beforeEach, afterAll, beforeAll } from "vitest"; import { createRoot, createSignal, flush } from "solid-js"; -import { autofocus, createAutofocus } from "../src/index.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.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(() => { @@ -20,31 +29,37 @@ beforeEach(() => { 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 => { - // 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(); + settle(); expect(focused).toBe(el); - dispose(); }); - test("doesn't focus when autofocus HTML attribute is absent", () => { + test("doesn't focus when autofocus attribute is absent", () => { const el = document.createElement("button"); const dispose = createRoot(dispose => { @@ -53,35 +68,30 @@ describe("autofocus", () => { return dispose; }); - flush(); + settle(); expect(focused).toBe(null); - vi.runAllTimers(); - expect(focused).toBe(null); - dispose(); }); - }); +// ─── createAutofocus ────────────────────────────────────────────────────────── + describe("createAutofocus", () => { const el = document.createElement("button"), el2 = document.createElement("button"); - test("createAutofocus focuses the element", () => { + test("focuses the element", () => { const dispose = createRoot(dispose => { createAutofocus(() => el); return dispose; }); - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); + settle(); expect(focused).toBe(el); - dispose(); }); - test("createAutofocus works with signal", () => { + test("works with signal — focuses when signal is set", () => { const [ref, setRef] = createSignal(); const dispose = createRoot(dispose => { @@ -89,28 +99,340 @@ describe("createAutofocus", () => { return dispose; }); - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); + settle(); expect(focused).toBe(null); setRef(el); - flush(); - expect(focused).toBe(null); - vi.runAllTimers(); + settle(); expect(focused).toBe(el); setRef(el2); - flush(); - expect(focused).toBe(el); - vi.runAllTimers(); + settle(); expect(focused).toBe(el2); dispose(); setRef(el); - expect(focused).toBe(el2); vi.runAllTimers(); - expect(focused).toBe(el2); + 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/focus/test/server.test.ts b/packages/focus/test/server.test.ts index d8c2b5f8b..9b4ae4800 100644 --- a/packages/focus/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/utils/src/index.ts b/packages/utils/src/index.ts index 7c5e33cb1..49c91bdd5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -311,6 +311,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 ──────────────────────────────────────────────────────── /** From fe09eb93c3c6bc831e154e2698ac1232ac4a0fc9 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Wed, 13 May 2026 12:15:30 -0400 Subject: [PATCH 3/7] Move createFocusSignal to focus package out of active-element --- packages/active-element/package.json | 3 +- packages/active-element/src/index.ts | 59 ++------------------- packages/active-element/test/index.test.ts | 47 +--------------- packages/active-element/test/server.test.ts | 9 +--- packages/focus/package.json | 5 +- packages/focus/src/focusSignal.ts | 52 ++++++++++++++++++ packages/focus/src/index.ts | 1 + packages/focus/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ pnpm-workspace.yaml | 4 ++ 10 files changed, 74 insertions(+), 112 deletions(-) create mode 100644 packages/focus/src/focusSignal.ts diff --git a/packages/active-element/package.json b/packages/active-element/package.json index 0c43a6a2a..728c38941 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" }, diff --git a/packages/active-element/src/index.ts b/packages/active-element/src/index.ts index 307bcb7c3..f32293dab 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 { type Directive, createHydratableSignal } from "@solid-primitives/utils"; +import { makeEventListener } from "@solid-primitives/event-listener"; declare module "solid-js" { namespace JSX { @@ -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/focus/package.json b/packages/focus/package.json index 02e232612..14b74bf26 100644 --- a/packages/focus/package.json +++ b/packages/focus/package.json @@ -24,7 +24,9 @@ "list": [ "autofocus", "createAutofocus", - "createFocusTrap" + "createFocusTrap", + "makeFocusListener", + "createFocusSignal" ], "category": "Inputs" }, @@ -66,6 +68,7 @@ "solid-js": "^2.0.0-beta.12" }, "dependencies": { + "@solid-primitives/event-listener": "workspace:^", "@solid-primitives/utils": "workspace:^" }, "typesVersions": {}, diff --git a/packages/focus/src/focusSignal.ts b/packages/focus/src/focusSignal.ts new file mode 100644 index 000000000..1b6e90426 --- /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 () => 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/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/index.ts b/packages/focus/src/index.ts index bc812c568..678243b0b 100644 --- a/packages/focus/src/index.ts +++ b/packages/focus/src/index.ts @@ -2,3 +2,4 @@ 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/tsconfig.json b/packages/focus/tsconfig.json index dc1970e16..b9b2b6782 100644 --- a/packages/focus/tsconfig.json +++ b/packages/focus/tsconfig.json @@ -6,6 +6,9 @@ "rootDir": "src" }, "references": [ + { + "path": "../event-listener" + }, { "path": "../utils" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1623f9551..acb201640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: packages/focus: dependencies: + '@solid-primitives/event-listener': + specifier: workspace:^ + version: link:../event-listener '@solid-primitives/utils': specifier: workspace:^ version: link:../utils diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f621dcf45..92f944bbb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,10 @@ packages: - packages/* - site +allowBuilds: + '@parcel/watcher': set this to true or false + esbuild: set this to true or false + onlyBuiltDependencies: - "@parcel/watcher" - esbuild From 5a89eb75d3180e6c0be4050c3134a2e94a5600e9 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Wed, 13 May 2026 12:19:03 -0400 Subject: [PATCH 4/7] Bumped active-element to beta 12 --- packages/active-element/package.json | 6 ++++-- packages/active-element/src/index.ts | 4 ++-- pnpm-lock.yaml | 7 +++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/active-element/package.json b/packages/active-element/package.json index 728c38941..2230c4aa4 100644 --- a/packages/active-element/package.json +++ b/packages/active-element/package.json @@ -54,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 f32293dab..4afc9b0d2 100644 --- a/packages/active-element/src/index.ts +++ b/packages/active-element/src/index.ts @@ -1,5 +1,5 @@ import { type Accessor, type JSX } from "solid-js"; -import { isServer } from "solid-js/web"; +import { isServer } from "@solidjs/web"; import { type Directive, createHydratableSignal } from "@solid-primitives/utils"; import { makeEventListener } from "@solid-primitives/event-listener"; @@ -11,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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb201640..717822004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,9 +96,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: From 1e9a524006c8197968bd81e3b7f7966f3a47a57b Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Wed, 13 May 2026 12:20:51 -0400 Subject: [PATCH 5/7] Added proper changesets --- .changeset/active-element-solid2-migration.md | 18 ++++++++++++++++++ .changeset/focus-solid2-migration.md | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .changeset/active-element-solid2-migration.md 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/focus-solid2-migration.md b/.changeset/focus-solid2-migration.md index 8720ad291..fb95c2c84 100644 --- a/.changeset/focus-solid2-migration.md +++ b/.changeset/focus-solid2-migration.md @@ -2,11 +2,11 @@ "@solid-primitives/focus": major --- -Migrate to Solid.js v2.0 (beta.10) +Migrate to Solid.js v2.0 (beta.12) ## Breaking Changes -**Peer dependencies**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. +**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 @@ -23,3 +23,18 @@ Migrate to Solid.js v2.0 (beta.10) - `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 + ``` From def8ee04c213ed0eb20711cf9448ccba1da32109 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Thu, 14 May 2026 20:06:34 -0400 Subject: [PATCH 6/7] Better types, adapting to 2.0 and adding undefinedes --- packages/event-listener/src/types.ts | 2 +- packages/focus/package.json | 4 ++++ packages/focus/src/focusTrap.ts | 14 +++++++------- packages/utils/src/index.ts | 13 +++++++------ 4 files changed, 19 insertions(+), 14 deletions(-) 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/focus/package.json b/packages/focus/package.json index 14b74bf26..c848eaa19 100644 --- a/packages/focus/package.json +++ b/packages/focus/package.json @@ -7,6 +7,10 @@ { "name": "Jasmin Noetzli", "url": "https://github.com/GiyoMoon" + }, + { + "name": "David Di Biase", + "url": "https://github.com/davedbase" } ], "license": "MIT", diff --git a/packages/focus/src/focusTrap.ts b/packages/focus/src/focusTrap.ts index 669b0312b..06391279e 100644 --- a/packages/focus/src/focusTrap.ts +++ b/packages/focus/src/focusTrap.ts @@ -16,7 +16,7 @@ const EVENT_OPTIONS = { bubbles: false, cancelable: true } as const; export type CreateFocusTrapProps = { /** Element to trap focus within. */ - element: MaybeAccessor; + element: MaybeAccessor; /** Whether the focus trap is active. Default: `true` */ enabled?: MaybeAccessor; /** @@ -28,7 +28,7 @@ export type CreateFocusTrapProps = { * Element to focus when the trap activates. * Default: the first focusable element inside `element`. */ - initialFocusElement?: MaybeAccessor; + initialFocusElement?: MaybeAccessor; /** * Restore focus to the element that was focused before the trap activated * when the trap is deactivated. Default: `true` @@ -38,7 +38,7 @@ export type CreateFocusTrapProps = { * Element to focus when the trap deactivates. * Default: the element that was focused before the trap activated. */ - finalFocusElement?: MaybeAccessor; + finalFocusElement?: MaybeAccessor; /** * Callback fired when focus moves into the trap. * Call `event.preventDefault()` to suppress the focus move. @@ -66,8 +66,8 @@ export type CreateFocusTrapProps = { * ``` */ export const createFocusTrap = (props: CreateFocusTrapProps): void => { - const [focusableElements, setFocusableElements] = createSignal( - null, + const [focusableElements, setFocusableElements] = createSignal( + undefined, INTERNAL_OPTIONS, ); @@ -176,7 +176,7 @@ export const createFocusTrap = (props: CreateFocusTrapProps): void => { return () => { if (observeChanges) observer.disconnect(); - setFocusableElements(null); + setFocusableElements(undefined); triggerRestoreFocus(container); }; }, @@ -186,7 +186,7 @@ export const createFocusTrap = (props: CreateFocusTrapProps): void => { createEffect( () => focusableElements(), elements => { - if (elements === null || elements.length !== 0) return; + if (!elements || elements.length !== 0) return; document.addEventListener("keydown", preventTab); return () => document.removeEventListener("keydown", preventTab); }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 4c0eab146..1556ee589 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"; @@ -47,11 +48,11 @@ export const noop = (() => void 0) 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 ___ From 669039f76ee56217e85da7e4efc006c63994d502 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Thu, 14 May 2026 20:56:10 -0400 Subject: [PATCH 7/7] =?UTF-8?q?Jeremy=20wants=20undefined=20instead=20of?= =?UTF-8?q?=20void=200=20since=20it=E2=80=99s=20cursed=20:p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/focus/src/focusSignal.ts | 6 +++--- packages/utils/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/focus/src/focusSignal.ts b/packages/focus/src/focusSignal.ts index 1b6e90426..c24e09e6c 100644 --- a/packages/focus/src/focusSignal.ts +++ b/packages/focus/src/focusSignal.ts @@ -22,10 +22,10 @@ export function makeFocusListener( useCapture = true, ): VoidFunction { if (isServer) { - return () => void 0; + return () => {}; } - const clear1 = makeEventListener(target, "blur", callback.bind(void 0, false), useCapture); - const clear2 = makeEventListener(target, "focus", callback.bind(void 0, true), useCapture); + const clear1 = makeEventListener(target, "blur", callback.bind(undefined, false), useCapture); + const clear2 = makeEventListener(target, "focus", callback.bind(undefined, true), useCapture); return () => (clear1(), clear2()); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1556ee589..8213e539d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -44,7 +44,7 @@ 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;