diff --git a/README.md b/README.md index 7ec7ddb..bed5a0b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ bundle size react vue + solid + svelte typescript

@@ -85,6 +87,35 @@ import { SlotText } from "slot-text/vue"; ``` +## 🔷 Solid + +```tsx +import "slot-text/style.css"; +import { createSignal } from "solid-js"; +import { slotText } from "slot-text/solid"; + +const [label, setLabel] = createSignal("Copy"); + + +``` + +## 🧡 Svelte + +```svelte + + + +``` + ## ⚙️ Options | Option | Default | Description | @@ -132,6 +163,7 @@ essentially any font you'd use for those. ## 📝 Notes - Browser-only DOM utility, zero runtime dependencies. -- React and Vue are optional peer dependencies — plain JS users don't need them. +- React, Vue, Solid, and Svelte are optional peer dependencies — plain JS users + don't need them. - Import the CSS once before using the animation. - Low-level helpers also exported: `buildSlotText`, `animateSlotText`, `chromatic`. diff --git a/examples/env.d.ts b/examples/env.d.ts new file mode 100644 index 0000000..615a741 --- /dev/null +++ b/examples/env.d.ts @@ -0,0 +1 @@ +declare module "slot-text/style.css"; diff --git a/examples/solid.tsx b/examples/solid.tsx new file mode 100644 index 0000000..04a6faa --- /dev/null +++ b/examples/solid.tsx @@ -0,0 +1,30 @@ +import { createSignal } from "solid-js"; +import "slot-text/style.css"; +import { chromatic } from "slot-text"; +import { slotText } from "slot-text/solid"; + +void slotText; + +export function CopyButton() { + const [copied, setCopied] = createSignal(false); + + function copy() { + setCopied(true); + window.setTimeout(() => setCopied(false), 1400); + } + + return ( + + ); +} diff --git a/examples/svelte.svelte b/examples/svelte.svelte new file mode 100644 index 0000000..9866f47 --- /dev/null +++ b/examples/svelte.svelte @@ -0,0 +1,27 @@ + + + diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..d15d8a3 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "skipLibCheck": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "paths": { + "slot-text": ["../src/index.ts"], + "slot-text/solid": ["../src/solid.ts"], + "slot-text/svelte": ["../src/svelte.ts"] + } + }, + "include": ["env.d.ts", "solid.tsx", "*.svelte", "../src/**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index a2d337b..1172095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,18 +12,28 @@ "@types/react": "^19.2.17", "happy-dom": "^20.10.2", "react": "^19.2.7", + "solid-js": "^1.9.13", + "svelte": "^5.56.3", "typescript": "^5.8.3", "vitest": "^4.1.8", "vue": "^3.5.35" }, "peerDependencies": { "react": ">=18 <20", + "solid-js": ">=1.8 <2", + "svelte": ">=4 <6", "vue": ">=3.4 <4" }, "peerDependenciesMeta": { "react": { "optional": true }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, "vue": { "optional": true } @@ -113,6 +123,38 @@ "tslib": "^2.4.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -120,6 +162,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -420,6 +473,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -476,6 +539,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -725,6 +795,29 @@ "dev": true, "license": "MIT" }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -735,6 +828,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/buffer-image-size": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", @@ -758,6 +861,16 @@ "node": ">=18" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -782,6 +895,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -802,6 +922,31 @@ "dev": true, "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -871,6 +1016,16 @@ "node": ">=20.0.0" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1132,6 +1287,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1275,6 +1437,29 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1282,6 +1467,18 @@ "dev": true, "license": "ISC" }, + "node_modules/solid-js": { + "version": "1.9.13", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.13.tgz", + "integrity": "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.0", + "seroval-plugins": "~1.5.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1306,6 +1503,34 @@ "dev": true, "license": "MIT" }, + "node_modules/svelte": { + "version": "5.56.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", + "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1617,6 +1842,13 @@ "optional": true } } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index d77f5f8..577b37a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dist", "examples", "style.css", + "style.css.d.ts", "README.md", "LICENSE" ], @@ -29,11 +30,24 @@ "types": "./dist/vue.d.ts", "import": "./dist/vue.js" }, - "./style.css": "./style.css" + "./solid": { + "types": "./dist/solid.d.ts", + "import": "./dist/solid.js" + }, + "./svelte": { + "types": "./dist/svelte.d.ts", + "import": "./dist/svelte.js" + }, + "./style.css": { + "types": "./style.css.d.ts", + "default": "./style.css" + } }, "types": "./dist/index.d.ts", "peerDependencies": { "react": ">=18 <20", + "solid-js": ">=1.8 <2", + "svelte": ">=4 <6", "vue": ">=3.4 <4" }, "peerDependenciesMeta": { @@ -42,6 +56,12 @@ }, "vue": { "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true } }, "scripts": { @@ -55,6 +75,8 @@ "@types/react": "^19.2.17", "happy-dom": "^20.10.2", "react": "^19.2.7", + "solid-js": "^1.9.13", + "svelte": "^5.56.3", "typescript": "^5.8.3", "vitest": "^4.1.8", "vue": "^3.5.35" diff --git a/src/solid.ts b/src/solid.ts new file mode 100644 index 0000000..efe1f4c --- /dev/null +++ b/src/solid.ts @@ -0,0 +1,40 @@ +import { createRenderEffect, onCleanup, type Accessor } from "solid-js"; +import { slotText as createSlotText, type SlotOptions } from "./index.js"; + +export interface SlotTextParams { + text: string; + options?: SlotOptions; +} + +export function slotText(element: HTMLElement, accessor: Accessor) { + const initial = accessor(); + const controller = createSlotText(element, initial.text); + let previousText = initial.text; + let isFirstRun = true; + + createRenderEffect(() => { + const next = accessor(); + + if (isFirstRun) { + isFirstRun = false; + return; + } + + if (next.text !== previousText) { + previousText = next.text; + controller.set(next.text, next.options); + } + }); + + onCleanup(() => { + controller.destroy(); + }); +} + +declare module "solid-js" { + namespace JSX { + interface Directives { + slotText: SlotTextParams; + } + } +} diff --git a/src/svelte.ts b/src/svelte.ts new file mode 100644 index 0000000..8b6d977 --- /dev/null +++ b/src/svelte.ts @@ -0,0 +1,22 @@ +import { slotText as createSlotText, type SlotOptions } from "./index.js"; + +export interface SlotTextParams { + text: string; + options?: SlotOptions; +} + +export function slotText(element: HTMLElement, params: SlotTextParams) { + const controller = createSlotText(element, params.text); + let previousText = params.text; + + return { + update(params: SlotTextParams) { + if (params.text === previousText) return; + previousText = params.text; + controller.set(params.text, params.options); + }, + destroy() { + controller.destroy(); + }, + }; +} diff --git a/style.css.d.ts b/style.css.d.ts new file mode 100644 index 0000000..9cb05fc --- /dev/null +++ b/style.css.d.ts @@ -0,0 +1,2 @@ +declare const stylesheet: string; +export default stylesheet; diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts new file mode 100644 index 0000000..0813ba4 --- /dev/null +++ b/tests/adapters.test.ts @@ -0,0 +1,103 @@ +import { createRoot, createSignal } from "solid-js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type SlotOptions } from "../src/index.js"; +import { slotText as solidSlotText } from "../src/solid.js"; +import { slotText as svelteSlotText } from "../src/svelte.js"; + +let el: HTMLElement; + +beforeEach(() => { + vi.useFakeTimers(); + el = document.createElement("span"); + document.body.appendChild(el); +}); + +afterEach(() => { + vi.useRealTimers(); + el.remove(); +}); + +const readText = () => + Array.from(el.querySelectorAll(".char-slot")) + .map((s) => s.dataset.char ?? "") + .join(""); + +const hasTintedFace = () => + Array.from(el.querySelectorAll(".char-face")).some( + (face) => face.style.color === "red", + ); + +describe("Solid adapter", () => { + it("updates text through the directive lifecycle", () => { + let dispose!: () => void; + let setText!: (text: string) => string; + + createRoot((rootDispose) => { + dispose = rootDispose; + const [text, setLabel] = createSignal("Copy"); + setText = setLabel; + + solidSlotText(el, () => ({ text: text() })); + expect(readText()).toBe("Copy"); + }); + + setText("Copied"); + vi.runAllTimers(); + expect(readText()).toBe("Copied"); + + dispose(); + expect(el.classList.contains("slot-text")).toBe(false); + expect(el.textContent).toBe("Copied"); + }); + + it("does not pin initial options as defaults for later updates", () => { + let setText!: (text: string) => string; + let setOptions!: (options: SlotOptions | undefined) => void; + + createRoot((dispose) => { + const [text, setLabel] = createSignal("Copy"); + const [options, setNextOptions] = createSignal({ + color: "red", + }); + setText = setLabel; + setOptions = setNextOptions; + + solidSlotText(el, () => ({ text: text(), options: options() })); + + setOptions(undefined); + setText("Done"); + + expect(hasTintedFace()).toBe(false); + + dispose(); + }); + }); +}); + +describe("Svelte adapter", () => { + it("updates text through the action lifecycle", () => { + const action = svelteSlotText(el, { text: "Copy" }); + expect(readText()).toBe("Copy"); + + action.update({ text: "Copied" }); + vi.runAllTimers(); + expect(readText()).toBe("Copied"); + + action.destroy(); + expect(el.classList.contains("slot-text")).toBe(false); + expect(el.textContent).toBe("Copied"); + }); + + it("does not pin initial options as defaults for later updates", () => { + const action = svelteSlotText(el, { + text: "Copy", + options: { color: "red" }, + }); + + action.update({ text: "Done" }); + + expect(hasTintedFace()).toBe(false); + + action.destroy(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d22e699..9974afc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + conditions: ["browser"], + }, test: { environment: "happy-dom", },