diff --git a/.changeset/cursor-solid2-migration.md b/.changeset/cursor-solid2-migration.md new file mode 100644 index 000000000..c5a0427a8 --- /dev/null +++ b/.changeset/cursor-solid2-migration.md @@ -0,0 +1,19 @@ +--- +"@solid-primitives/cursor": major +--- + +Migrate to Solid.js v2.0 and add new primitives + +## Breaking Changes + +**Peer dependency**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. + +- `isServer` now imported from `@solidjs/web` (not `solid-js/web`) +- `createElementCursor` and `createBodyCursor` updated to the split compute/apply effect pattern required by Solid 2.0 — cleanup is returned from the apply phase instead of using `onCleanup` + +## New Exports + +- `makeBodyCursor(cursor)` — sets cursor on body immediately, returns a cleanup function +- `makeElementCursor(target, cursor)` — sets cursor on an element immediately, returns a cleanup function +- `createDragCursor(target, options?)` — reactively sets `"grab"` on a target element and switches to `"grabbing"` on the body during pointer drag +- `cursorRef(cursor)` — ref factory for inline JSX use: `
` diff --git a/packages/cursor/README.md b/packages/cursor/README.md index e01d17576..9c8a752a4 100644 --- a/packages/cursor/README.md +++ b/packages/cursor/README.md @@ -8,10 +8,14 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/cursor?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/cursor) [![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-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Two simple primitives for setting cursor css property reactively. +Utilities for setting the CSS cursor property via reactive primitives (`createBodyCursor`, `createElementCursor`) and imperative APIs (`makeBodyCursor`, `makeElementCursor`). -- [`createElementCursor`](#createelementcursor) - Set provided cursor to given HTML Element styles reactively. -- [`createBodyCursor`](#createbodycursor) - Set selected cursor to body element styles reactively. +- [`makeBodyCursor`](#makebodycursor) - Set cursor on body immediately; returns a cleanup function. +- [`makeElementCursor`](#makeelementcursor) - Set cursor on an element immediately; returns a cleanup function. +- [`createBodyCursor`](#createbodycursor) - Set cursor on body reactively. +- [`createElementCursor`](#createelementcursor) - Set cursor on a specific element reactively. +- [`createDragCursor`](#createdragcursor) - Show `grab`/`grabbing` cursors during pointer drag. +- [`cursorRef`](#cursorref) - Ref factory for inline JSX use. ## Installation @@ -23,14 +27,50 @@ yarn add @solid-primitives/cursor pnpm add @solid-primitives/cursor ``` -## `createElementCursor` +## `makeBodyCursor` + +Sets a cursor on the body element immediately and returns a cleanup function that restores the previous value. No reactive owner required. + +```ts +import { makeBodyCursor } from "@solid-primitives/cursor"; + +// Show a loading cursor during an async operation +const restore = makeBodyCursor("wait"); +await doSomething(); +restore(); +``` + +## `makeElementCursor` + +Sets a cursor on a specific element immediately and returns a cleanup function that restores the previous value. No reactive owner required. + +```ts +import { makeElementCursor } from "@solid-primitives/cursor"; + +const el = document.querySelector("#element")!; +const restore = makeElementCursor(el, "not-allowed"); +// ... later +restore(); +``` + +## `createBodyCursor` + +Sets a cursor on the body element reactively. The cursor is removed when the owner is disposed or when the signal returns a falsy value. + +```ts +import { createBodyCursor } from "@solid-primitives/cursor"; + +const [cursor, setCursor] = createSignal("pointer"); +const [enabled, setEnabled] = createSignal(true); + +createBodyCursor(() => enabled() && cursor()); -Set provided cursor to given HTML Element styles reactively. +setCursor("help"); +``` -It takes two arguments: +## `createElementCursor` -- `element` - HTMLElement or a reactive signal returning one. Returning falsy value will unset the cursor. -- `cursor` - Cursor css property. E.g. "pointer", "grab", "zoom-in", "wait", etc. +Sets a cursor on a specific element reactively. Accepts an element or a signal returning one — returning a falsy value unsets the cursor. ```ts import { createElementCursor } from "@solid-primitives/cursor"; @@ -44,23 +84,39 @@ createElementCursor(() => enabled() && target, cursor); setCursor("help"); ``` -## `createBodyCursor` +## `createDragCursor` + +Shows `"grab"` on a target element and switches to `"grabbing"` on the body during a pointer drag. Setting `"grabbing"` on the body ensures the cursor renders correctly everywhere during drag, not just over the target element. + +```ts +import { createDragCursor } from "@solid-primitives/cursor"; -Set selected cursor to body element styles reactively. +const [ref, setRef] = createSignal(); -It takes only one argument: +createDragCursor(ref); -- `cursor` - Signal returing a cursor css property. E.g. "pointer", "grab", "zoom-in", "wait", etc. Returning falsy value will unset the cursor. +
Drag me
+``` + +Custom cursor values can be provided via options: ```ts -import { createBodyCursor } from "@solid-primitives/cursor"; +createDragCursor(el, { grab: "crosshair", grabbing: "move" }); +``` -const [cursor, setCursor] = createSignal("pointer"); -const [enabled, setEnabled] = createSignal(true); +## `cursorRef` -createBodyCursor(() => enabled() && cursor()); +A ref factory for setting a cursor inline in JSX. Accepts a static cursor value or a reactive signal. The cursor is removed when the component unmounts. -setCursor("help"); +```tsx +import { cursorRef } from "@solid-primitives/cursor"; + +// Static +
...
; + +// Reactive +const [cursor, setCursor] = createSignal("pointer"); +
...
; ``` ## Changelog diff --git a/packages/cursor/package.json b/packages/cursor/package.json index ca52fabee..3527088ee 100644 --- a/packages/cursor/package.json +++ b/packages/cursor/package.json @@ -1,7 +1,7 @@ { "name": "@solid-primitives/cursor", - "version": "0.1.3", - "description": "Two simple primitives for setting cursor css property reactively.", + "version": "0.2.0", + "description": "Primitives for setting CSS cursor property reactively.", "author": "Damian Tarnawski ", "contributors": [], "license": "MIT", @@ -17,8 +17,12 @@ "name": "cursor", "stage": 0, "list": [ + "makeBodyCursor", + "makeElementCursor", + "createBodyCursor", "createElementCursor", - "createBodyCursor" + "createDragCursor", + "cursorRef" ], "category": "Utilities" }, @@ -55,10 +59,12 @@ "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" } } diff --git a/packages/cursor/src/index.ts b/packages/cursor/src/index.ts index 6420521bb..e290589f3 100644 --- a/packages/cursor/src/index.ts +++ b/packages/cursor/src/index.ts @@ -1,47 +1,55 @@ -import { type Accessor, createEffect, onCleanup } from "solid-js"; -import { isServer } from "solid-js/web"; -import { access, type FalsyValue, type MaybeAccessor } from "@solid-primitives/utils"; +import { type Accessor, createEffect, createSignal } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, noop, type FalsyValue, type MaybeAccessor } from "@solid-primitives/utils"; +import { type CursorProperty } from "./types.js"; -export type CursorProperty = - | "-moz-grab" - | "-webkit-grab" - | "alias" - | "all-scroll" - | "auto" - | "cell" - | "col-resize" - | "context-menu" - | "copy" - | "crosshair" - | "default" - | "e-resize" - | "ew-resize" - | "grab" - | "grabbing" - | "help" - | "move" - | "n-resize" - | "ne-resize" - | "nesw-resize" - | "no-drop" - | "none" - | "not-allowed" - | "ns-resize" - | "nw-resize" - | "nwse-resize" - | "pointer" - | "progress" - | "row-resize" - | "s-resize" - | "se-resize" - | "sw-resize" - | "text" - | "vertical-text" - | "w-resize" - | "wait" - | "zoom-in" - | "zoom-out" - | (string & {}); +export type { CursorProperty }; + +/** + * Set selected {@link cursor} to body element styles immediately. + * + * Returns a cleanup function that restores the previous cursor. + * + * @param cursor Cursor css property. E.g. "pointer", "grab", "zoom-in", "wait", etc. + * + * @example + * ```ts + * const restore = makeBodyCursor("wait"); + * // ... async operation ... + * restore(); + * ``` + */ +export function makeBodyCursor(cursor: CursorProperty): VoidFunction { + if (isServer) return noop; + return makeElementCursor(document.body, cursor); +} + +/** + * Set selected {@link cursor} to {@link target} element styles immediately. + * + * Returns a cleanup function that restores the previous cursor. + * + * @param target HTMLElement to set the cursor on. + * @param cursor Cursor css property. E.g. "pointer", "grab", "zoom-in", "wait", etc. + * + * @example + * ```ts + * const restore = makeElementCursor(el, "wait"); + * // ... async operation ... + * restore(); + * ``` + */ +export function makeElementCursor(target: HTMLElement, cursor: CursorProperty): VoidFunction { + if (isServer) return noop; + const prevValue = target.style.getPropertyValue("cursor"); + const prevPriority = target.style.getPropertyPriority("cursor"); + target.style.setProperty("cursor", cursor, "important"); + return () => { + prevValue + ? target.style.setProperty("cursor", prevValue, prevPriority) + : target.style.removeProperty("cursor"); + }; +} /** * Set selected {@link cursor} to {@link target} styles reactively. @@ -66,14 +74,26 @@ export function createElementCursor( ): void { if (isServer) return; - createEffect(() => { - const el = access(target); - const cursorValue = access(cursor); + type State = { el: HTMLElement | FalsyValue; cursorValue: CursorProperty }; + + const compute = (): State => ({ + el: access(target), + cursorValue: access(cursor), + }); + + const apply = ({ el, cursorValue }: State) => { if (!el) return; - const overwritten = el.style.cursor; + const prevValue = el.style.getPropertyValue("cursor"); + const prevPriority = el.style.getPropertyPriority("cursor"); el.style.setProperty("cursor", cursorValue, "important"); - onCleanup(() => (el.style.cursor = overwritten)); - }); + return () => { + prevValue + ? el.style.setProperty("cursor", prevValue, prevPriority) + : el.style.removeProperty("cursor"); + }; + }; + + createEffect(compute, apply); } /** @@ -94,11 +114,93 @@ export function createElementCursor( export function createBodyCursor(cursor: Accessor): void { if (isServer) return; - createEffect(() => { - const cursorValue = cursor(); + createEffect(cursor, cursorValue => { if (!cursorValue) return; - const overwritten = document.body.style.cursor; + const prevValue = document.body.style.getPropertyValue("cursor"); + const prevPriority = document.body.style.getPropertyPriority("cursor"); document.body.style.setProperty("cursor", cursorValue, "important"); - onCleanup(() => (document.body.style.cursor = overwritten)); + return () => { + prevValue + ? document.body.style.setProperty("cursor", prevValue, prevPriority) + : document.body.style.removeProperty("cursor"); + }; }); } + +/** + * Reactively sets "grab" cursor on {@link target} and switches to "grabbing" on the body during drag. + * + * Setting "grabbing" on the body ensures the cursor renders correctly everywhere during drag, + * not just over the target element. + * + * @param target HTMLElement or a reactive signal returning one. Returning falsy value will disable the cursor. + * @param options Optional overrides for the grab and grabbing cursor values. + * + * @example + * ```ts + * const [ref, setRef] = createSignal(); + * + * createDragCursor(ref); + * + *
Drag me
+ * ``` + */ +export function createDragCursor( + target: Accessor | HTMLElement, + options?: { grab?: CursorProperty; grabbing?: CursorProperty }, +): void { + if (isServer) return; + + const grab = options?.grab ?? "grab"; + const grabbing = options?.grabbing ?? "grabbing"; + const [dragging, setDragging] = createSignal(false); + + // During drag, "grabbing" is set on body so it shows globally. + // "grab" is cleared from the element so the body cursor can inherit through — + // element inline styles (even without !important) would otherwise win over body. + createBodyCursor(() => dragging() && grabbing); + createElementCursor(() => { + const el = access(target); + return dragging() ? false : el; + }, grab); + + createEffect( + () => access(target), + el => { + if (!el) { + setDragging(false); + return; + } + const onDown = () => setDragging(true); + const onUp = () => setDragging(false); + el.addEventListener("pointerdown", onDown); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onUp); + return () => { + el.removeEventListener("pointerdown", onDown); + document.removeEventListener("pointerup", onUp); + document.removeEventListener("pointercancel", onUp); + }; + }, + ); +} + +/** + * Returns a ref callback that sets a cursor on the element it is attached to. + * + * Accepts a static cursor value or a reactive signal. The cursor is removed when the + * component unmounts. + * + * @example + * ```tsx + * // static + *
...
+ * + * // reactive + * const [cursor, setCursor] = createSignal("pointer"); + *
...
+ * ``` + */ +export function cursorRef(cursor: MaybeAccessor): (el: HTMLElement) => void { + return el => createElementCursor(el, cursor); +} diff --git a/packages/cursor/src/types.ts b/packages/cursor/src/types.ts new file mode 100644 index 000000000..56e727df9 --- /dev/null +++ b/packages/cursor/src/types.ts @@ -0,0 +1,40 @@ +export type CursorProperty = + | "-moz-grab" + | "-webkit-grab" + | "alias" + | "all-scroll" + | "auto" + | "cell" + | "col-resize" + | "context-menu" + | "copy" + | "crosshair" + | "default" + | "e-resize" + | "ew-resize" + | "grab" + | "grabbing" + | "help" + | "move" + | "n-resize" + | "ne-resize" + | "nesw-resize" + | "no-drop" + | "none" + | "not-allowed" + | "ns-resize" + | "nw-resize" + | "nwse-resize" + | "pointer" + | "progress" + | "row-resize" + | "s-resize" + | "se-resize" + | "sw-resize" + | "text" + | "vertical-text" + | "w-resize" + | "wait" + | "zoom-in" + | "zoom-out" + | (string & {}); diff --git a/packages/cursor/test/index.test.ts b/packages/cursor/test/index.test.ts index 019cb787c..8d3fff1ef 100644 --- a/packages/cursor/test/index.test.ts +++ b/packages/cursor/test/index.test.ts @@ -1,6 +1,54 @@ import { describe, test, expect } from "vitest"; -import { createRoot, createSignal } from "solid-js"; -import { createBodyCursor, createElementCursor, CursorProperty } from "../src/index.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { + createBodyCursor, + createDragCursor, + createElementCursor, + cursorRef, + makeBodyCursor, + makeElementCursor, + type CursorProperty, +} from "../src/index.js"; + +describe("makeBodyCursor", () => { + test("sets cursor and returns cleanup", () => { + const restore = makeBodyCursor("pointer"); + expect(document.body.style.cursor).toBe("pointer"); + restore(); + expect(document.body.style.cursor).toBe(""); + }); + + test("restores nested cursors in stack order", () => { + const restore1 = makeBodyCursor("pointer"); + const restore2 = makeBodyCursor("help"); + expect(document.body.style.cursor).toBe("help"); + restore2(); + expect(document.body.style.cursor).toBe("pointer"); + restore1(); + expect(document.body.style.cursor).toBe(""); + }); +}); + +describe("makeElementCursor", () => { + test("sets cursor on element and returns cleanup", () => { + const el = document.createElement("div"); + const restore = makeElementCursor(el, "pointer"); + expect(el.style.cursor).toBe("pointer"); + restore(); + expect(el.style.cursor).toBe(""); + }); + + test("restores previous cursor value", () => { + const el = document.createElement("div"); + const restore1 = makeElementCursor(el, "pointer"); + const restore2 = makeElementCursor(el, "help"); + expect(el.style.cursor).toBe("help"); + restore2(); + expect(el.style.cursor).toBe("pointer"); + restore1(); + expect(el.style.cursor).toBe(""); + }); +}); describe("createBodyCursor", () => { test("switches previous cursor to provided one", () => { @@ -11,16 +59,20 @@ describe("createBodyCursor", () => { createBodyCursor(() => enabled() && cursor()); return dispose; }); + flush(); expect(document.body.style.cursor).toBe("pointer"); setCursor("help"); + flush(); expect(document.body.style.cursor).toBe("help"); setEnabled(false); + flush(); expect(document.body.style.cursor, "unsets cursor").toBe(""); setEnabled(true); + flush(); expect(document.body.style.cursor).toBe("help"); dispose(); @@ -38,16 +90,20 @@ describe("createElementCursor", () => { createElementCursor(() => enabled() && div, cursor); return dispose; }); + flush(); expect(div.style.cursor).toBe("pointer"); setCursor("help"); + flush(); expect(div.style.cursor).toBe("help"); setEnabled(false); + flush(); expect(div.style.cursor, "unsets cursor").toBe(""); setEnabled(true); + flush(); expect(div.style.cursor).toBe("help"); dispose(); @@ -65,28 +121,35 @@ describe("createElementCursor", () => { createElementCursor(() => enabled() && target(), cursor); return dispose; }); + flush(); expect(div1.style.cursor).toBe("pointer"); setCursor("help"); + flush(); expect(div1.style.cursor).toBe("help"); setTarget(div2); + flush(); expect(div1.style.cursor).toBe(""); expect(div2.style.cursor).toBe("help"); setEnabled(false); + flush(); expect(div2.style.cursor).toBe(""); setTarget(div1); + flush(); expect(div1.style.cursor).toBe(""); expect(div2.style.cursor).toBe(""); setCursor("pointer"); + flush(); expect(div1.style.cursor).toBe(""); expect(div2.style.cursor).toBe(""); setEnabled(true); + flush(); expect(div1.style.cursor).toBe("pointer"); expect(div2.style.cursor).toBe(""); @@ -94,3 +157,129 @@ describe("createElementCursor", () => { expect(div1.style.cursor).toBe(""); }); }); + +describe("createDragCursor", () => { + test("shows grab/grabbing cursors during drag", () => { + const el = document.createElement("div"); + + const dispose = createRoot(dispose => { + createDragCursor(el); + return dispose; + }); + flush(); + + expect(el.style.cursor).toBe("grab"); + expect(document.body.style.cursor).toBe(""); + + el.dispatchEvent(new Event("pointerdown")); + flush(); + expect(document.body.style.cursor).toBe("grabbing"); + expect(el.style.cursor).toBe(""); + + document.dispatchEvent(new Event("pointerup")); + flush(); + expect(document.body.style.cursor).toBe(""); + expect(el.style.cursor).toBe("grab"); + + dispose(); + expect(el.style.cursor).toBe(""); + }); + + test("resets on pointercancel", () => { + const el = document.createElement("div"); + + const dispose = createRoot(dispose => { + createDragCursor(el); + return dispose; + }); + flush(); + + el.dispatchEvent(new Event("pointerdown")); + flush(); + expect(document.body.style.cursor).toBe("grabbing"); + + document.dispatchEvent(new Event("pointercancel")); + flush(); + expect(document.body.style.cursor).toBe(""); + expect(el.style.cursor).toBe("grab"); + + dispose(); + }); + + test("supports custom cursor values", () => { + const el = document.createElement("div"); + + const dispose = createRoot(dispose => { + createDragCursor(el, { grab: "crosshair", grabbing: "move" }); + return dispose; + }); + flush(); + + expect(el.style.cursor).toBe("crosshair"); + + el.dispatchEvent(new Event("pointerdown")); + flush(); + expect(document.body.style.cursor).toBe("move"); + + document.dispatchEvent(new Event("pointerup")); + flush(); + expect(el.style.cursor).toBe("crosshair"); + + dispose(); + }); + + test("cleans up listeners and cursors on dispose", () => { + const el = document.createElement("div"); + + const dispose = createRoot(dispose => { + createDragCursor(el); + return dispose; + }); + flush(); + + dispose(); + expect(el.style.cursor).toBe(""); + expect(document.body.style.cursor).toBe(""); + + el.dispatchEvent(new Event("pointerdown")); + flush(); + expect(document.body.style.cursor).toBe(""); + }); +}); + +describe("cursorRef", () => { + test("applies cursor to element", () => { + const el = document.createElement("div"); + + const dispose = createRoot(dispose => { + cursorRef("pointer")(el); + return dispose; + }); + flush(); + + expect(el.style.cursor).toBe("pointer"); + + dispose(); + expect(el.style.cursor).toBe(""); + }); + + test("reacts to cursor signal changes", () => { + const el = document.createElement("div"); + const [cursor, setCursor] = createSignal("pointer"); + + const dispose = createRoot(dispose => { + cursorRef(cursor)(el); + return dispose; + }); + flush(); + + expect(el.style.cursor).toBe("pointer"); + + setCursor("help"); + flush(); + expect(el.style.cursor).toBe("help"); + + dispose(); + expect(el.style.cursor).toBe(""); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee1923c5f..c274838a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,9 +233,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/date: dependencies: