From 75998a6904c434550bd10764121bbdf671868d44 Mon Sep 17 00:00:00 2001 From: Loki <66067772+fahdlaabi@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:17:26 +0700 Subject: [PATCH] feat: add Vue support and update README.md --- README.md | 58 ++- bun.lock | 49 ++- package.json | 30 +- src/core/store.ts | 207 +++++++++++ src/{ => core}/types.ts | 13 +- src/index.ts | 5 +- src/{ => react}/icons.tsx | 0 src/react/index.ts | 22 ++ src/{ => react}/sileo.tsx | 3 +- src/react/toaster.tsx | 262 ++++++++++++++ src/styles.css | 526 +++++++++++++-------------- src/toast.tsx | 443 ----------------------- src/vue/icons.ts | 63 ++++ src/vue/index.ts | 26 ++ src/vue/sileo.ts | 737 ++++++++++++++++++++++++++++++++++++++ src/vue/toaster.ts | 273 ++++++++++++++ 16 files changed, 1991 insertions(+), 726 deletions(-) create mode 100644 src/core/store.ts rename src/{ => core}/types.ts (72%) rename src/{ => react}/icons.tsx (100%) create mode 100644 src/react/index.ts rename src/{ => react}/sileo.tsx (99%) create mode 100644 src/react/toaster.tsx delete mode 100644 src/toast.tsx create mode 100644 src/vue/icons.ts create mode 100644 src/vue/index.ts create mode 100644 src/vue/sileo.ts create mode 100644 src/vue/toaster.ts diff --git a/README.md b/README.md index f55e6dc..a22ae60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Sileo

-

An opinionated, physics-based toast component for React.

+

An opinionated, physics-based toast component for React & Vue.

Try Out   /   Docs

@@ -11,10 +11,11 @@ npm i sileo ``` -### Getting Started +### React ```tsx import { sileo, Toaster } from "sileo"; +import "sileo/styles.css"; export default function App() { return ( @@ -24,6 +25,59 @@ export default function App() { ); } + +// Trigger toasts anywhere +sileo.success({ title: "Saved", description: "Your changes were saved." }); +sileo.error({ title: "Error", description: "Something went wrong." }); +``` + +### Vue + +```vue + + + ``` +### API + +The `sileo` API is identical across both frameworks: + +```ts +sileo.show({ title: "Hello" }); +sileo.success({ title: "Saved", description: "Your changes were saved." }); +sileo.error({ title: "Error", description: "Something went wrong." }); +sileo.warning({ title: "Warning", description: "Proceed with caution." }); +sileo.info({ title: "Info", description: "Here's some information." }); + +// Promise-based +sileo.promise(fetchData(), { + loading: { title: "Loading..." }, + success: (data) => ({ title: "Done", description: `Loaded ${data.length} items.` }), + error: (err) => ({ title: "Failed", description: String(err) }), +}); + +// Dismiss & clear +sileo.dismiss(id); +sileo.clear(); +``` + +### Toaster Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `position` | `"top-left" \| "top-center" \| "top-right" \| "bottom-left" \| "bottom-center" \| "bottom-right"` | `"top-right"` | Where toasts appear | +| `offset` | `number \| string \| { top?, right?, bottom?, left? }` | — | Viewport offset | +| `options` | `Partial` | — | Default options for all toasts | + For detailed docs, click here: https://sileo.aaryan.design diff --git a/bun.lock b/bun.lock index 942225d..752019f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,18 +5,31 @@ "name": "sileo", "devDependencies": { "bunchee": "^6.9.4", + "vue": "^3", }, "peerDependencies": { "react": ">=18", "react-dom": ">=18", + "vue": ">=3", }, + "optionalPeers": [ + "react", + "react-dom", + "vue", + ], }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@fastify/deepmerge": ["@fastify/deepmerge@1.3.0", "", {}, "sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], @@ -115,6 +128,24 @@ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.28", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.28", "", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="], + + "@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -131,10 +162,14 @@ "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -163,6 +198,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanospinner": ["nanospinner@1.2.2", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -171,11 +208,9 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], - - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -191,10 +226,10 @@ "rollup-preserve-directives": ["rollup-preserve-directives@1.1.3", "", { "dependencies": { "magic-string": "^0.30.5" }, "peerDependencies": { "rollup": "^2.0.0 || ^3.0.0 || ^4.0.0" } }, "sha512-oXqxd6ZzkoQej8Qt0k+S/yvO2+S4CEVEVv2g85oL15o0cjAKTKEuo2MzyA8FcsBBXbtytBzBMFAbhvQg4YyPUQ=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -207,6 +242,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], diff --git a/package.json b/package.json index cbe0dc7..43fc3e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sileo", "version": "0.1.0", - "description": "An opinionated, physics based toast notification library for react.", + "description": "An opinionated, physics based toast notification library for react and vue.", "license": "MIT", "repository": { "type": "git", @@ -19,6 +19,17 @@ }, "default": "./dist/index.js" }, + "./vue": { + "import": { + "types": "./dist/vue/index.d.mts", + "default": "./dist/vue/index.mjs" + }, + "require": { + "types": "./dist/vue/index.d.ts", + "default": "./dist/vue/index.js" + }, + "default": "./dist/vue/index.js" + }, "./styles.css": "./dist/styles.css" }, "main": "./dist/index.js", @@ -32,9 +43,22 @@ }, "peerDependencies": { "react": ">=18", - "react-dom": ">=18" + "react-dom": ">=18", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } }, "devDependencies": { - "bunchee": "^6.9.4" + "bunchee": "^6.9.4", + "vue": "^3" } } diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..7ea5cb3 --- /dev/null +++ b/src/core/store.ts @@ -0,0 +1,207 @@ +import type { SileoOptions, SileoPosition, SileoState } from "./types"; + +/* -------------------------------- Constants ------------------------------- */ + +export const DEFAULT_DURATION = 6000; +export const EXIT_DURATION = DEFAULT_DURATION * 0.1; +const AUTO_EXPAND_DELAY = DEFAULT_DURATION * 0.025; +const AUTO_COLLAPSE_DELAY = DEFAULT_DURATION - 2000; + +export const pillAlign = (pos: SileoPosition) => + pos.includes("right") ? "right" : pos.includes("center") ? "center" : "left"; +export const expandDir = (pos: SileoPosition) => + pos.startsWith("top") ? ("bottom" as const) : ("top" as const); + +/* ---------------------------------- Types --------------------------------- */ + +export interface InternalSileoOptions extends SileoOptions { + id?: string; + state?: SileoState; +} + +export interface SileoItem extends InternalSileoOptions { + id: string; + instanceId: string; + exiting?: boolean; + autoExpandDelayMs?: number; + autoCollapseDelayMs?: number; +} + +/* ------------------------------ Global State ------------------------------ */ + +export type SileoListener = (toasts: SileoItem[]) => void; + +export const store = { + toasts: [] as SileoItem[], + listeners: new Set(), + position: "top-right" as SileoPosition, + options: undefined as Partial | undefined, + + emit() { + for (const fn of this.listeners) fn(this.toasts); + }, + + update(fn: (prev: SileoItem[]) => SileoItem[]) { + this.toasts = fn(this.toasts); + this.emit(); + }, +}; + +let idCounter = 0; +const generateId = () => + `${++idCounter}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +export const timeoutKey = (t: SileoItem) => `${t.id}:${t.instanceId}`; + +/* ------------------------------- Toast API -------------------------------- */ + +export const dismissToast = (id: string) => { + const item = store.toasts.find((t) => t.id === id); + if (!item || item.exiting) return; + + store.update((prev) => + prev.map((t) => (t.id === id ? { ...t, exiting: true } : t)), + ); + + setTimeout( + () => store.update((prev) => prev.filter((t) => t.id !== id)), + EXIT_DURATION, + ); +}; + +const resolveAutopilot = ( + opts: InternalSileoOptions, + duration: number | null, +): { expandDelayMs?: number; collapseDelayMs?: number } => { + if (opts.autopilot === false || !duration || duration <= 0) return {}; + const cfg = typeof opts.autopilot === "object" ? opts.autopilot : undefined; + const clamp = (v: number) => Math.min(duration, Math.max(0, v)); + return { + expandDelayMs: clamp(cfg?.expand ?? AUTO_EXPAND_DELAY), + collapseDelayMs: clamp(cfg?.collapse ?? AUTO_COLLAPSE_DELAY), + }; +}; + +const mergeOptions = (options: InternalSileoOptions) => ({ + ...store.options, + ...options, + styles: { ...store.options?.styles, ...options.styles }, +}); + +const buildSileoItem = ( + merged: InternalSileoOptions, + id: string, + fallbackPosition?: SileoPosition, +): SileoItem => { + const duration = merged.duration ?? DEFAULT_DURATION; + const auto = resolveAutopilot(merged, duration); + return { + ...merged, + id, + instanceId: generateId(), + position: merged.position ?? fallbackPosition ?? store.position, + autoExpandDelayMs: auto.expandDelayMs, + autoCollapseDelayMs: auto.collapseDelayMs, + }; +}; + +const createToast = (options: InternalSileoOptions) => { + const live = store.toasts.filter((t) => !t.exiting); + const merged = mergeOptions(options); + + const id = merged.id ?? "sileo-default"; + const prev = live.find((t) => t.id === id); + const item = buildSileoItem(merged, id, prev?.position); + + if (prev) { + store.update((p) => p.map((t) => (t.id === id ? item : t))); + } else { + store.update((p) => [...p.filter((t) => t.id !== id), item]); + } + return { id, duration: merged.duration ?? DEFAULT_DURATION }; +}; + +const updateToast = (id: string, options: InternalSileoOptions) => { + const existing = store.toasts.find((t) => t.id === id); + if (!existing) return; + + const item = buildSileoItem(mergeOptions(options), id, existing.position); + store.update((prev) => prev.map((t) => (t.id === id ? item : t))); +}; + +/* ----------------------------- Public API Types --------------------------- */ + +export interface SileoPromiseOptions { + loading: Pick, "title" | "icon">; + success: SileoOptions | ((data: T) => SileoOptions); + error: SileoOptions | ((err: unknown) => SileoOptions); + action?: SileoOptions | ((data: T) => SileoOptions); + position?: SileoPosition; +} + +export interface SileoAPI { + show(opts: SileoOptions): string; + success(opts: SileoOptions): string; + error(opts: SileoOptions): string; + warning(opts: SileoOptions): string; + info(opts: SileoOptions): string; + action(opts: SileoOptions): string; + promise( + promise: Promise | (() => Promise), + opts: SileoPromiseOptions, + ): Promise; + dismiss(id: string): void; + clear(position?: SileoPosition): void; +} + +/* ----------------------------- API Singleton ------------------------------ */ + +export const sileo: SileoAPI = { + show: (opts) => createToast(opts).id, + success: (opts) => createToast({ ...opts, state: "success" }).id, + error: (opts) => createToast({ ...opts, state: "error" }).id, + warning: (opts) => createToast({ ...opts, state: "warning" }).id, + info: (opts) => createToast({ ...opts, state: "info" }).id, + action: (opts) => createToast({ ...opts, state: "action" }).id, + + promise: ( + promise: Promise | (() => Promise), + opts: SileoPromiseOptions, + ): Promise => { + const { id } = createToast({ + ...opts.loading, + state: "loading", + duration: null, + position: opts.position, + }); + + const p = typeof promise === "function" ? promise() : promise; + + p.then((data) => { + if (opts.action) { + const actionOpts = + typeof opts.action === "function" ? opts.action(data) : opts.action; + updateToast(id, { ...actionOpts, state: "action", id }); + } else { + const successOpts = + typeof opts.success === "function" + ? opts.success(data) + : opts.success; + updateToast(id, { ...successOpts, state: "success", id }); + } + }).catch((err) => { + const errorOpts = + typeof opts.error === "function" ? opts.error(err) : opts.error; + updateToast(id, { ...errorOpts, state: "error", id }); + }); + + return p; + }, + + dismiss: dismissToast, + + clear: (position?: SileoPosition) => + store.update((prev) => + position ? prev.filter((t) => t.position !== position) : [], + ), +}; diff --git a/src/types.ts b/src/core/types.ts similarity index 72% rename from src/types.ts rename to src/core/types.ts index 0e51f43..57ad40b 100644 --- a/src/types.ts +++ b/src/core/types.ts @@ -1,5 +1,3 @@ -import type { ReactNode } from "react"; - export type SileoState = | "success" | "loading" @@ -31,15 +29,20 @@ export const SILEO_POSITIONS = [ export type SileoPosition = (typeof SILEO_POSITIONS)[number]; -export interface SileoOptions { +export interface SileoOptions { title?: string; - description?: ReactNode | string; + description?: Renderable | string; position?: SileoPosition; duration?: number | null; - icon?: ReactNode | null; + icon?: Renderable | null; styles?: SileoStyles; fill?: string; roundness?: number; autopilot?: boolean | { expand?: number; collapse?: number }; button?: SileoButton; } + +export type SileoOffsetValue = number | string; +export type SileoOffsetConfig = Partial< + Record<"top" | "right" | "bottom" | "left", SileoOffsetValue> +>; diff --git a/src/index.ts b/src/index.ts index f4ca345..a18be95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,12 @@ import "./styles.css"; -export { sileo, Toaster } from "./toast"; +export { sileo, Toaster } from "./react"; export type { SileoButton, SileoOptions, SileoPosition, + SileoPromiseOptions, SileoState, SileoStyles, -} from "./types"; +} from "./react"; diff --git a/src/icons.tsx b/src/react/icons.tsx similarity index 100% rename from src/icons.tsx rename to src/react/icons.tsx diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..e9b240d --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,22 @@ +"use client"; + +import "../styles.css"; + +import type { ReactNode } from "react"; +import { sileo as _sileo } from "../core/store"; +import type { SileoAPI, SileoPromiseOptions as CoreSileoPromiseOptions } from "../core/store"; +import type { SileoOptions as CoreSileoOptions } from "../core/types"; + +export const sileo = _sileo as unknown as SileoAPI; + +export { Toaster } from "./toaster"; + +export type SileoOptions = CoreSileoOptions; +export type SileoPromiseOptions = CoreSileoPromiseOptions; + +export type { + SileoButton, + SileoPosition, + SileoState, + SileoStyles, +} from "../core/types"; diff --git a/src/sileo.tsx b/src/react/sileo.tsx similarity index 99% rename from src/sileo.tsx rename to src/react/sileo.tsx index d316a0a..b1b05d6 100644 --- a/src/sileo.tsx +++ b/src/react/sileo.tsx @@ -11,8 +11,7 @@ import { useRef, useState, } from "react"; -import type { SileoButton, SileoState, SileoStyles } from "./types"; -import "./styles.css"; +import type { SileoButton, SileoState, SileoStyles } from "../core/types"; import { ArrowRight, Check, diff --git a/src/react/toaster.tsx b/src/react/toaster.tsx new file mode 100644 index 0000000..0e5949a --- /dev/null +++ b/src/react/toaster.tsx @@ -0,0 +1,262 @@ +import { + type CSSProperties, + type MouseEventHandler, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Sileo } from "./sileo"; +import { + SILEO_POSITIONS, + type SileoOffsetConfig, + type SileoOffsetValue, + type SileoOptions, + type SileoPosition, +} from "../core/types"; +import { + DEFAULT_DURATION, + dismissToast, + store, + timeoutKey, + pillAlign, + expandDir, + type SileoItem, + type SileoListener, +} from "../core/store"; + +/* ---------------------------------- Types --------------------------------- */ + +export interface SileoToasterProps { + children?: ReactNode; + position?: SileoPosition; + offset?: SileoOffsetValue | SileoOffsetConfig; + options?: Partial; +} + +/* ------------------------------ Toaster Component ------------------------- */ + +export function Toaster({ + children, + position = "top-right", + offset, + options, +}: SileoToasterProps) { + const [toasts, setToasts] = useState(store.toasts); + const [activeId, setActiveId] = useState(); + + const hoverRef = useRef(false); + const timersRef = useRef(new Map()); + const listRef = useRef(toasts); + const latestRef = useRef(undefined); + const handlersCache = useRef( + new Map< + string, + { + enter: MouseEventHandler; + leave: MouseEventHandler; + dismiss: () => void; + } + >() + ); + + useEffect(() => { + store.position = position; + store.options = options; + }, [position, options]); + + const clearAllTimers = useCallback(() => { + for (const t of timersRef.current.values()) clearTimeout(t); + timersRef.current.clear(); + }, []); + + const schedule = useCallback((items: SileoItem[]) => { + if (hoverRef.current) return; + + for (const item of items) { + if (item.exiting) continue; + const key = timeoutKey(item); + if (timersRef.current.has(key)) continue; + + const dur = item.duration ?? DEFAULT_DURATION; + if (dur === null || dur <= 0) continue; + + timersRef.current.set( + key, + window.setTimeout(() => dismissToast(item.id), dur) + ); + } + }, []); + + useEffect(() => { + const listener: SileoListener = (next) => setToasts(next); + store.listeners.add(listener); + return () => { + store.listeners.delete(listener); + clearAllTimers(); + }; + }, [clearAllTimers]); + + useEffect(() => { + listRef.current = toasts; + + const toastKeys = new Set(toasts.map(timeoutKey)); + const toastIds = new Set(toasts.map((t) => t.id)); + for (const [key, timer] of timersRef.current) { + if (!toastKeys.has(key)) { + clearTimeout(timer); + timersRef.current.delete(key); + } + } + for (const id of handlersCache.current.keys()) { + if (!toastIds.has(id)) handlersCache.current.delete(id); + } + + schedule(toasts); + }, [toasts, schedule]); + + const handleMouseEnterRef = + useRef>(null); + const handleMouseLeaveRef = + useRef>(null); + + handleMouseEnterRef.current = useCallback< + MouseEventHandler + >(() => { + if (hoverRef.current) return; + hoverRef.current = true; + clearAllTimers(); + }, [clearAllTimers]); + + handleMouseLeaveRef.current = useCallback< + MouseEventHandler + >(() => { + if (!hoverRef.current) return; + hoverRef.current = false; + schedule(listRef.current); + }, [schedule]); + + const latest = useMemo(() => { + for (let i = toasts.length - 1; i >= 0; i--) { + if (!toasts[i].exiting) return toasts[i].id; + } + return undefined; + }, [toasts]); + + useEffect(() => { + latestRef.current = latest; + setActiveId(latest); + }, [latest]); + + const getHandlers = useCallback((toastId: string) => { + let cached = handlersCache.current.get(toastId); + if (cached) return cached; + + cached = { + enter: ((e) => { + setActiveId((prev) => (prev === toastId ? prev : toastId)); + handleMouseEnterRef.current?.(e); + }) as MouseEventHandler, + leave: ((e) => { + setActiveId((prev) => + prev === latestRef.current ? prev : latestRef.current + ); + handleMouseLeaveRef.current?.(e); + }) as MouseEventHandler, + dismiss: () => dismissToast(toastId), + }; + + handlersCache.current.set(toastId, cached); + return cached; + }, []); + + const getViewportStyle = useCallback( + (pos: SileoPosition): CSSProperties | undefined => { + if (offset === undefined) return undefined; + + const o = + typeof offset === "object" + ? offset + : { top: offset, right: offset, bottom: offset, left: offset }; + + const s: CSSProperties = {}; + const px = (v: SileoOffsetValue) => + typeof v === "number" ? `${v}px` : v; + + if (pos.startsWith("top") && o.top) s.top = px(o.top); + if (pos.startsWith("bottom") && o.bottom) s.bottom = px(o.bottom); + if (pos.endsWith("left") && o.left) s.left = px(o.left); + if (pos.endsWith("right") && o.right) s.right = px(o.right); + + return s; + }, + [offset] + ); + + const byPosition = useMemo(() => { + const map = {} as Partial>; + for (const t of toasts) { + const pos = t.position ?? position; + const arr = map[pos]; + if (arr) { + arr.push(t); + } else { + map[pos] = [t]; + } + } + return map; + }, [toasts, position]); + + return ( + <> + {children} + {SILEO_POSITIONS.map((pos) => { + const items = byPosition[pos]; + if (!items?.length) return null; + + const pill = pillAlign(pos); + const expand = expandDir(pos); + + return ( +
+ {items.map((item) => { + const h = getHandlers(item.id); + return ( + + ); + })} +
+ ); + })} + + ); +} diff --git a/src/styles.css b/src/styles.css index b9eba57..3fe8fbe 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,490 +1,490 @@ /* -------------------------------- Variables ------------------------------- */ :root { - --sileo-spring-easing: linear( - 0, - 0.002 0.6%, - 0.007 1.2%, - 0.015 1.8%, - 0.026 2.4%, - 0.041 3.1%, - 0.06 3.8%, - 0.108 5.3%, - 0.157 6.6%, - 0.214 8%, - 0.467 13.7%, - 0.577 16.3%, - 0.631 17.7%, - 0.682 19.1%, - 0.73 20.5%, - 0.771 21.8%, - 0.808 23.1%, - 0.844 24.5%, - 0.874 25.8%, - 0.903 27.2%, - 0.928 28.6%, - 0.952 30.1%, - 0.972 31.6%, - 0.988 33.1%, - 1.01 35.7%, - 1.025 38.5%, - 1.034 41.6%, - 1.038 45%, - 1.035 50.1%, - 1.012 64.2%, - 1.003 73%, - 0.999 83.7%, - 1 - ); - - --sileo-duration: 600ms; - --sileo-height: 40px; - --sileo-width: 350px; - - --sileo-state-success: oklch(0.723 0.219 142.136); - --sileo-state-loading: oklch(0.556 0 0); - --sileo-state-error: oklch(0.637 0.237 25.331); - --sileo-state-warning: oklch(0.795 0.184 86.047); - --sileo-state-info: oklch(0.685 0.169 237.323); - --sileo-state-action: oklch(0.623 0.214 259.815); + --sileo-spring-easing: linear( + 0, + 0.002 0.6%, + 0.007 1.2%, + 0.015 1.8%, + 0.026 2.4%, + 0.041 3.1%, + 0.06 3.8%, + 0.108 5.3%, + 0.157 6.6%, + 0.214 8%, + 0.467 13.7%, + 0.577 16.3%, + 0.631 17.7%, + 0.682 19.1%, + 0.73 20.5%, + 0.771 21.8%, + 0.808 23.1%, + 0.844 24.5%, + 0.874 25.8%, + 0.903 27.2%, + 0.928 28.6%, + 0.952 30.1%, + 0.972 31.6%, + 0.988 33.1%, + 1.01 35.7%, + 1.025 38.5%, + 1.034 41.6%, + 1.038 45%, + 1.035 50.1%, + 1.012 64.2%, + 1.003 73%, + 0.999 83.7%, + 1 + ); + + --sileo-duration: 600ms; + --sileo-height: 40px; + --sileo-width: 350px; + + --sileo-state-success: oklch(0.723 0.219 142.136); + --sileo-state-loading: oklch(0.556 0 0); + --sileo-state-error: oklch(0.637 0.237 25.331); + --sileo-state-warning: oklch(0.795 0.184 86.047); + --sileo-state-info: oklch(0.685 0.169 237.323); + --sileo-state-action: oklch(0.623 0.214 259.815); } /* ---------------------------------- Toast --------------------------------- */ [data-sileo-toast] { - position: relative; - cursor: pointer; - pointer-events: auto; - touch-action: none; - border: 0; - background: transparent; - padding: 0; - width: var(--sileo-width); - height: var(--_h, var(--sileo-height)); - opacity: 0; - transform: translateZ(0) scale(0.95); - transform-origin: center; - contain: layout style; - overflow: visible; + position: relative; + cursor: pointer; + pointer-events: auto; + touch-action: none; + border: 0; + background: transparent; + padding: 0; + width: var(--sileo-width); + height: var(--_h, var(--sileo-height)); + opacity: 0; + transform: translateZ(0) scale(0.95); + transform-origin: center; + contain: layout style; + overflow: visible; } [data-sileo-toast][data-state="loading"] { - cursor: default; + cursor: default; } [data-sileo-toast][data-ready="true"] { - opacity: 1; - transform: translateZ(0) scale(1); - transition: - transform calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), - opacity calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), - margin-bottom calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), - margin-top calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), - height var(--sileo-duration) var(--sileo-spring-easing); + opacity: 1; + transform: translateZ(0) scale(1); + transition: + transform calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), + opacity calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), + margin-bottom calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), + margin-top calc(var(--sileo-duration) * 0.66) var(--sileo-spring-easing), + height var(--sileo-duration) var(--sileo-spring-easing); } /* Entry animation direction */ [data-sileo-viewport][data-position^="top"] - [data-sileo-toast]:not([data-ready="true"]) { - transform: translateY(-6px) scale(0.95); + [data-sileo-toast]:not([data-ready="true"]) { + transform: translateY(-6px) scale(0.95); } [data-sileo-viewport][data-position^="bottom"] - [data-sileo-toast]:not([data-ready="true"]) { - transform: translateY(6px) scale(0.95); + [data-sileo-toast]:not([data-ready="true"]) { + transform: translateY(6px) scale(0.95); } /* Exit */ [data-sileo-toast][data-ready="true"][data-exiting="true"] { - opacity: 0; - pointer-events: none; + opacity: 0; + pointer-events: none; } [data-sileo-viewport][data-position^="top"] - [data-sileo-toast][data-ready="true"][data-exiting="true"] { - transform: translateY(-6px) scale(0.95); + [data-sileo-toast][data-ready="true"][data-exiting="true"] { + transform: translateY(-6px) scale(0.95); } [data-sileo-viewport][data-position^="bottom"] - [data-sileo-toast][data-ready="true"][data-exiting="true"] { - transform: translateY(6px) scale(0.95); + [data-sileo-toast][data-ready="true"][data-exiting="true"] { + transform: translateY(6px) scale(0.95); } /* ------------------------------- SVG Canvas ------------------------------- */ [data-sileo-canvas] { - position: absolute; - left: 0; - right: 0; - pointer-events: none; - transform: translateZ(0); - contain: layout style; - overflow: visible; + position: absolute; + left: 0; + right: 0; + pointer-events: none; + transform: translateZ(0); + contain: layout style; + overflow: visible; } [data-sileo-canvas][data-edge="top"] { - bottom: 0; - transform: scaleY(-1) translateZ(0); + bottom: 0; + transform: scaleY(-1) translateZ(0); } [data-sileo-canvas][data-edge="bottom"] { - top: 0; + top: 0; } [data-sileo-svg] { - overflow: visible; + overflow: visible; } /* --------------------------------- Shapes --------------------------------- */ [data-sileo-pill], [data-sileo-body] { - transform-box: fill-box; - transform-origin: 50% 0%; + transform-box: fill-box; + transform-origin: 50% 0%; } [data-sileo-pill] { - transform: scaleY(var(--_sy, 1)); - width: var(--_pw); - height: var(--_ph); + transform: scaleY(var(--_sy, 1)); + width: var(--_pw); + height: var(--_ph); } [data-sileo-body] { - transform: scaleY(var(--_by, 0)); - opacity: var(--_by, 0); + transform: scaleY(var(--_by, 0)); + opacity: var(--_by, 0); } [data-sileo-toast][data-ready="true"] [data-sileo-pill] { - transition: - transform var(--sileo-duration) var(--sileo-spring-easing), - width var(--sileo-duration) var(--sileo-spring-easing), - x var(--sileo-duration) var(--sileo-spring-easing); + transition: + transform var(--sileo-duration) var(--sileo-spring-easing), + width var(--sileo-duration) var(--sileo-spring-easing), + x var(--sileo-duration) var(--sileo-spring-easing); } [data-sileo-toast][data-ready="true"][data-expanded="true"] [data-sileo-pill] { - transition-delay: calc(var(--sileo-duration) * 0.08); + transition-delay: calc(var(--sileo-duration) * 0.08); } [data-sileo-toast][data-ready="true"] [data-sileo-body] { - transition: - transform var(--sileo-duration) var(--sileo-spring-easing), - opacity var(--sileo-duration) var(--sileo-spring-easing); + transition: + transform var(--sileo-duration) var(--sileo-spring-easing), + opacity var(--sileo-duration) var(--sileo-spring-easing); } /* --------------------------------- Header --------------------------------- */ [data-sileo-header] { - position: absolute; - z-index: 20; - display: flex; - align-items: center; - padding: 0.5rem; - height: var(--sileo-height); - overflow: hidden; - left: var(--_px, 0px); - transform: var(--_ht); - max-width: var(--_pw); + position: absolute; + z-index: 20; + display: flex; + align-items: center; + padding: 0.5rem; + height: var(--sileo-height); + overflow: hidden; + left: var(--_px, 0px); + transform: var(--_ht); + max-width: var(--_pw); } [data-sileo-toast][data-ready="true"] [data-sileo-header] { - transition: - transform var(--sileo-duration) var(--sileo-spring-easing), - left var(--sileo-duration) var(--sileo-spring-easing), - max-width var(--sileo-duration) var(--sileo-spring-easing); + transition: + transform var(--sileo-duration) var(--sileo-spring-easing), + left var(--sileo-duration) var(--sileo-spring-easing), + max-width var(--sileo-duration) var(--sileo-spring-easing); } [data-sileo-header][data-edge="top"] { - bottom: 0; + bottom: 0; } [data-sileo-header][data-edge="bottom"] { - top: 0; + top: 0; } /* Header inner morphing */ [data-sileo-header-stack] { - position: relative; - display: inline-flex; - align-items: center; - height: 100%; + position: relative; + display: inline-flex; + align-items: center; + height: 100%; } [data-sileo-header-inner] { - display: flex; - align-items: center; - gap: 0.5rem; - white-space: nowrap; - opacity: 1; - filter: blur(0px); - will-change: opacity, filter; + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + opacity: 1; + filter: blur(0px); + will-change: opacity, filter; } [data-sileo-header-inner][data-layer="current"] { - animation: sileo-header-enter var(--sileo-duration) var(--sileo-spring-easing) - both; + animation: sileo-header-enter var(--sileo-duration) var(--sileo-spring-easing) + both; } [data-sileo-header-inner][data-layer="prev"] { - position: absolute; - left: 0; - top: 0; - pointer-events: none; + position: absolute; + left: 0; + top: 0; + pointer-events: none; } [data-sileo-header-inner][data-exiting="true"] { - animation: sileo-header-exit 300ms ease forwards; + animation: sileo-header-exit 300ms ease forwards; } /* ---------------------------------- Badge --------------------------------- */ [data-sileo-badge] { - display: flex; - height: 24px; - width: 24px; - flex-shrink: 0; - align-items: center; - justify-content: center; - padding: 2px; - box-sizing: border-box; - border-radius: 9999px; - color: var(--sileo-tone, currentColor); - background-color: var(--sileo-tone-bg, transparent); + display: flex; + height: 24px; + width: 24px; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 2px; + box-sizing: border-box; + border-radius: 9999px; + color: var(--sileo-tone, currentColor); + background-color: var(--sileo-tone-bg, transparent); } /* ---------------------------------- Title --------------------------------- */ [data-sileo-title] { - font-size: 0.825rem; - line-height: 1rem; - font-weight: 500; - text-transform: capitalize; - color: var(--sileo-tone, currentColor); + font-size: 0.825rem; + line-height: 1rem; + font-weight: 500; + text-transform: capitalize; + color: var(--sileo-tone, currentColor); } /* ------------------------------ State Colors ------------------------------ */ :is([data-sileo-badge], [data-sileo-title])[data-state] { - --_c: var(--sileo-state-success); - --sileo-tone: var(--_c); - --sileo-tone-bg: color-mix(in oklch, var(--_c) 20%, transparent); + --_c: var(--sileo-state-success); + --sileo-tone: var(--_c); + --sileo-tone-bg: color-mix(in oklch, var(--_c) 20%, transparent); } :is([data-sileo-badge], [data-sileo-title])[data-state="loading"] { - --_c: var(--sileo-state-loading); + --_c: var(--sileo-state-loading); } :is([data-sileo-badge], [data-sileo-title])[data-state="error"] { - --_c: var(--sileo-state-error); + --_c: var(--sileo-state-error); } :is([data-sileo-badge], [data-sileo-title])[data-state="warning"] { - --_c: var(--sileo-state-warning); + --_c: var(--sileo-state-warning); } :is([data-sileo-badge], [data-sileo-title])[data-state="info"] { - --_c: var(--sileo-state-info); + --_c: var(--sileo-state-info); } :is([data-sileo-badge], [data-sileo-title])[data-state="action"] { - --_c: var(--sileo-state-action); + --_c: var(--sileo-state-action); } /* --------------------------------- Content -------------------------------- */ [data-sileo-content] { - position: absolute; - left: 0; - z-index: 10; - width: 100%; - pointer-events: none; - opacity: var(--_co, 0); + position: absolute; + left: 0; + z-index: 10; + width: 100%; + pointer-events: none; + opacity: var(--_co, 0); } [data-sileo-content]:not([data-visible="true"]) { - content-visibility: hidden; + content-visibility: hidden; } [data-sileo-toast][data-ready="true"] [data-sileo-content] { - transition: opacity calc(var(--sileo-duration) * 0.08) - var(--sileo-spring-easing) calc(var(--sileo-duration) * 0.04); + transition: opacity calc(var(--sileo-duration) * 0.08) + var(--sileo-spring-easing) calc(var(--sileo-duration) * 0.04); } [data-sileo-content][data-edge="top"] { - top: 0; + top: 0; } [data-sileo-content][data-edge="bottom"] { - top: var(--sileo-height); + top: var(--sileo-height); } [data-sileo-content][data-visible="true"] { - pointer-events: auto; + pointer-events: auto; } [data-sileo-toast][data-ready="true"] - [data-sileo-content][data-visible="true"] { - transition: opacity var(--sileo-duration) var(--sileo-spring-easing) - calc(var(--sileo-duration) * 0.25); + [data-sileo-content][data-visible="true"] { + transition: opacity var(--sileo-duration) var(--sileo-spring-easing) + calc(var(--sileo-duration) * 0.25); } [data-sileo-description] { - width: 100%; - text-align: left; - padding: 1rem; - font-size: 0.875rem; - line-height: 1.25rem; - contain: layout style; - content-visibility: auto; + width: 100%; + text-align: left; + padding: 1rem; + font-size: 0.875rem; + line-height: 1.25rem; + contain: layout style; + content-visibility: auto; } /* --------------------------------- Button --------------------------------- */ [data-sileo-button] { - display: flex; - align-items: center; - justify-content: center; - height: 1.75rem; - padding: 0 0.625rem; - margin-top: 0.75rem; - border-radius: 9999px; - border: 0; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - color: var(--sileo-btn-color, currentColor); - background-color: var(--sileo-btn-bg, transparent); - transition: background-color 150ms ease; + display: flex; + align-items: center; + justify-content: center; + height: 1.75rem; + padding: 0 0.625rem; + margin-top: 0.75rem; + border-radius: 9999px; + border: 0; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + color: var(--sileo-btn-color, currentColor); + background-color: var(--sileo-btn-bg, transparent); + transition: background-color 150ms ease; } [data-sileo-button]:hover { - background-color: var(--sileo-btn-bg-hover, transparent); + background-color: var(--sileo-btn-bg-hover, transparent); } [data-sileo-button][data-state] { - --_c: var(--sileo-state-success); - --sileo-btn-color: var(--_c); - --sileo-btn-bg: color-mix(in oklch, var(--_c) 15%, transparent); - --sileo-btn-bg-hover: color-mix(in oklch, var(--_c) 25%, transparent); + --_c: var(--sileo-state-success); + --sileo-btn-color: var(--_c); + --sileo-btn-bg: color-mix(in oklch, var(--_c) 15%, transparent); + --sileo-btn-bg-hover: color-mix(in oklch, var(--_c) 25%, transparent); } [data-sileo-button][data-state="loading"] { - --_c: var(--sileo-state-loading); + --_c: var(--sileo-state-loading); } [data-sileo-button][data-state="error"] { - --_c: var(--sileo-state-error); + --_c: var(--sileo-state-error); } [data-sileo-button][data-state="warning"] { - --_c: var(--sileo-state-warning); + --_c: var(--sileo-state-warning); } [data-sileo-button][data-state="info"] { - --_c: var(--sileo-state-info); + --_c: var(--sileo-state-info); } [data-sileo-button][data-state="action"] { - --_c: var(--sileo-state-action); + --_c: var(--sileo-state-action); } /* -------------------------------- Animations ------------------------------ */ [data-sileo-icon="spin"] { - animation: sileo-spin 1s linear infinite; + animation: sileo-spin 1s linear infinite; } @keyframes sileo-spin { - to { - rotate: 360deg; - } + to { + rotate: 360deg; + } } @keyframes sileo-header-enter { - from { - opacity: 0; - filter: blur(6px); - } - to { - opacity: 1; - filter: blur(0px); - } + from { + opacity: 0; + filter: blur(6px); + } + to { + opacity: 1; + filter: blur(0px); + } } @keyframes sileo-header-exit { - from { - opacity: 1; - filter: blur(0px); - } - to { - opacity: 0; - filter: blur(6px); - } + from { + opacity: 1; + filter: blur(0px); + } + to { + opacity: 0; + filter: blur(6px); + } } /* -------------------------------- Viewports ------------------------------- */ [data-sileo-viewport] { - position: fixed; - z-index: 50; - display: flex; - gap: 0.75rem; - padding: 0.75rem; - pointer-events: none; - max-width: calc(100vw - 1.5rem); - contain: layout style; + position: fixed; + z-index: 50; + display: flex; + gap: 0.75rem; + padding: 0.75rem; + pointer-events: none; + max-width: calc(100vw - 1.5rem); + contain: layout style; } [data-sileo-viewport][data-position^="top"] - [data-sileo-toast]:not([data-ready="true"]) { - margin-bottom: calc(-1 * (var(--sileo-height) + 0.75rem)); + [data-sileo-toast]:not([data-ready="true"]) { + margin-bottom: calc(-1 * (var(--sileo-height) + 0.75rem)); } [data-sileo-viewport][data-position^="bottom"] - [data-sileo-toast]:not([data-ready="true"]) { - margin-top: calc(-1 * (var(--sileo-height) + 0.75rem)); + [data-sileo-toast]:not([data-ready="true"]) { + margin-top: calc(-1 * (var(--sileo-height) + 0.75rem)); } /* Vertical edge */ [data-sileo-viewport][data-position^="top"] { - top: 0; - flex-direction: column-reverse; + top: 0; + flex-direction: column-reverse; } [data-sileo-viewport][data-position^="bottom"] { - bottom: 0; - flex-direction: column; + bottom: 0; + flex-direction: column; } /* Horizontal alignment */ [data-sileo-viewport][data-position$="left"] { - left: 0; - align-items: flex-start; + left: 0; + align-items: flex-start; } [data-sileo-viewport][data-position$="right"] { - right: 0; - align-items: flex-end; + right: 0; + align-items: flex-end; } [data-sileo-viewport][data-position$="center"] { - left: 50%; - transform: translateX(-50%); - align-items: center; + left: 50%; + transform: translateX(-50%); + align-items: center; } @media (prefers-reduced-motion: no-preference) { - [data-sileo-toast][data-ready="true"]:hover, - [data-sileo-toast][data-ready="true"][data-exiting="true"] { - will-change: transform, opacity, height; - } + [data-sileo-toast][data-ready="true"]:hover, + [data-sileo-toast][data-ready="true"][data-exiting="true"] { + will-change: transform, opacity, height; + } } @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms; - animation-iteration-count: 1; - transition-duration: 0.01ms; - } + *, + *::before, + *::after { + animation-duration: 0.01ms; + animation-iteration-count: 1; + transition-duration: 0.01ms; + } } diff --git a/src/toast.tsx b/src/toast.tsx deleted file mode 100644 index 1dc7647..0000000 --- a/src/toast.tsx +++ /dev/null @@ -1,443 +0,0 @@ -import { - type CSSProperties, - type MouseEventHandler, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { Sileo } from "./sileo"; -import { - SILEO_POSITIONS, - type SileoOptions, - type SileoPosition, - type SileoState, -} from "./types"; - -/* -------------------------------- Constants ------------------------------- */ - -const DEFAULT_DURATION = 6000; -const EXIT_DURATION = DEFAULT_DURATION * 0.1; -const AUTO_EXPAND_DELAY = DEFAULT_DURATION * 0.025; -const AUTO_COLLAPSE_DELAY = DEFAULT_DURATION - 2000; - -const pillAlign = (pos: SileoPosition) => - pos.includes("right") ? "right" : pos.includes("center") ? "center" : "left"; -const expandDir = (pos: SileoPosition) => - pos.startsWith("top") ? ("bottom" as const) : ("top" as const); - -/* ---------------------------------- Types --------------------------------- */ - -interface InternalSileoOptions extends SileoOptions { - id?: string; - state?: SileoState; -} - -interface SileoItem extends InternalSileoOptions { - id: string; - instanceId: string; - exiting?: boolean; - autoExpandDelayMs?: number; - autoCollapseDelayMs?: number; -} - -type SileoOffsetValue = number | string; -type SileoOffsetConfig = Partial< - Record<"top" | "right" | "bottom" | "left", SileoOffsetValue> ->; - -export interface SileoToasterProps { - children?: ReactNode; - position?: SileoPosition; - offset?: SileoOffsetValue | SileoOffsetConfig; - options?: Partial; -} - -/* ------------------------------ Global State ------------------------------ */ - -type SileoListener = (toasts: SileoItem[]) => void; - -const store = { - toasts: [] as SileoItem[], - listeners: new Set(), - position: "top-right" as SileoPosition, - options: undefined as Partial | undefined, - - emit() { - for (const fn of this.listeners) fn(this.toasts); - }, - - update(fn: (prev: SileoItem[]) => SileoItem[]) { - this.toasts = fn(this.toasts); - this.emit(); - }, -}; - -let idCounter = 0; -const generateId = () => - `${++idCounter}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - -const timeoutKey = (t: SileoItem) => `${t.id}:${t.instanceId}`; - -/* ------------------------------- Toast API -------------------------------- */ - -const dismissToast = (id: string) => { - const item = store.toasts.find((t) => t.id === id); - if (!item || item.exiting) return; - - store.update((prev) => - prev.map((t) => (t.id === id ? { ...t, exiting: true } : t)), - ); - - setTimeout( - () => store.update((prev) => prev.filter((t) => t.id !== id)), - EXIT_DURATION, - ); -}; - -const resolveAutopilot = ( - opts: InternalSileoOptions, - duration: number | null, -): { expandDelayMs?: number; collapseDelayMs?: number } => { - if (opts.autopilot === false || !duration || duration <= 0) return {}; - const cfg = typeof opts.autopilot === "object" ? opts.autopilot : undefined; - const clamp = (v: number) => Math.min(duration, Math.max(0, v)); - return { - expandDelayMs: clamp(cfg?.expand ?? AUTO_EXPAND_DELAY), - collapseDelayMs: clamp(cfg?.collapse ?? AUTO_COLLAPSE_DELAY), - }; -}; - -const mergeOptions = (options: InternalSileoOptions) => ({ - ...store.options, - ...options, - styles: { ...store.options?.styles, ...options.styles }, -}); - -const buildSileoItem = ( - merged: InternalSileoOptions, - id: string, - fallbackPosition?: SileoPosition, -): SileoItem => { - const duration = merged.duration ?? DEFAULT_DURATION; - const auto = resolveAutopilot(merged, duration); - return { - ...merged, - id, - instanceId: generateId(), - position: merged.position ?? fallbackPosition ?? store.position, - autoExpandDelayMs: auto.expandDelayMs, - autoCollapseDelayMs: auto.collapseDelayMs, - }; -}; - -const createToast = (options: InternalSileoOptions) => { - const live = store.toasts.filter((t) => !t.exiting); - const merged = mergeOptions(options); - - const id = merged.id ?? "sileo-default"; - const prev = live.find((t) => t.id === id); - const item = buildSileoItem(merged, id, prev?.position); - - if (prev) { - store.update((p) => p.map((t) => (t.id === id ? item : t))); - } else { - store.update((p) => [...p.filter((t) => t.id !== id), item]); - } - return { id, duration: merged.duration ?? DEFAULT_DURATION }; -}; - -const updateToast = (id: string, options: InternalSileoOptions) => { - const existing = store.toasts.find((t) => t.id === id); - if (!existing) return; - - const item = buildSileoItem(mergeOptions(options), id, existing.position); - store.update((prev) => prev.map((t) => (t.id === id ? item : t))); -}; - -export interface SileoPromiseOptions { - loading: Pick; - success: SileoOptions | ((data: T) => SileoOptions); - error: SileoOptions | ((err: unknown) => SileoOptions); - action?: SileoOptions | ((data: T) => SileoOptions); - position?: SileoPosition; -} - -export const sileo = { - show: (opts: SileoOptions) => createToast(opts).id, - success: (opts: SileoOptions) => - createToast({ ...opts, state: "success" }).id, - error: (opts: SileoOptions) => createToast({ ...opts, state: "error" }).id, - warning: (opts: SileoOptions) => - createToast({ ...opts, state: "warning" }).id, - info: (opts: SileoOptions) => createToast({ ...opts, state: "info" }).id, - action: (opts: SileoOptions) => createToast({ ...opts, state: "action" }).id, - - promise: ( - promise: Promise | (() => Promise), - opts: SileoPromiseOptions, - ): Promise => { - const { id } = createToast({ - ...opts.loading, - state: "loading", - duration: null, - position: opts.position, - }); - - const p = typeof promise === "function" ? promise() : promise; - - p.then((data) => { - if (opts.action) { - const actionOpts = - typeof opts.action === "function" ? opts.action(data) : opts.action; - updateToast(id, { ...actionOpts, state: "action", id }); - } else { - const successOpts = - typeof opts.success === "function" - ? opts.success(data) - : opts.success; - updateToast(id, { ...successOpts, state: "success", id }); - } - }).catch((err) => { - const errorOpts = - typeof opts.error === "function" ? opts.error(err) : opts.error; - updateToast(id, { ...errorOpts, state: "error", id }); - }); - - return p; - }, - - dismiss: dismissToast, - - clear: (position?: SileoPosition) => - store.update((prev) => - position ? prev.filter((t) => t.position !== position) : [], - ), -}; - -/* ------------------------------ Toaster Component ------------------------- */ - -export function Toaster({ - children, - position = "top-right", - offset, - options, -}: SileoToasterProps) { - const [toasts, setToasts] = useState(store.toasts); - const [activeId, setActiveId] = useState(); - - const hoverRef = useRef(false); - const timersRef = useRef(new Map()); - const listRef = useRef(toasts); - const latestRef = useRef(undefined); - const handlersCache = useRef( - new Map< - string, - { - enter: MouseEventHandler; - leave: MouseEventHandler; - dismiss: () => void; - } - >(), - ); - - useEffect(() => { - store.position = position; - store.options = options; - }, [position, options]); - - const clearAllTimers = useCallback(() => { - for (const t of timersRef.current.values()) clearTimeout(t); - timersRef.current.clear(); - }, []); - - const schedule = useCallback((items: SileoItem[]) => { - if (hoverRef.current) return; - - for (const item of items) { - if (item.exiting) continue; - const key = timeoutKey(item); - if (timersRef.current.has(key)) continue; - - const dur = item.duration ?? DEFAULT_DURATION; - if (dur === null || dur <= 0) continue; - - timersRef.current.set( - key, - window.setTimeout(() => dismissToast(item.id), dur), - ); - } - }, []); - - useEffect(() => { - const listener: SileoListener = (next) => setToasts(next); - store.listeners.add(listener); - return () => { - store.listeners.delete(listener); - clearAllTimers(); - }; - }, [clearAllTimers]); - - useEffect(() => { - listRef.current = toasts; - - const toastKeys = new Set(toasts.map(timeoutKey)); - const toastIds = new Set(toasts.map((t) => t.id)); - for (const [key, timer] of timersRef.current) { - if (!toastKeys.has(key)) { - clearTimeout(timer); - timersRef.current.delete(key); - } - } - for (const id of handlersCache.current.keys()) { - if (!toastIds.has(id)) handlersCache.current.delete(id); - } - - schedule(toasts); - }, [toasts, schedule]); - - const handleMouseEnterRef = - useRef>(null); - const handleMouseLeaveRef = - useRef>(null); - - handleMouseEnterRef.current = useCallback< - MouseEventHandler - >(() => { - if (hoverRef.current) return; - hoverRef.current = true; - clearAllTimers(); - }, [clearAllTimers]); - - handleMouseLeaveRef.current = useCallback< - MouseEventHandler - >(() => { - if (!hoverRef.current) return; - hoverRef.current = false; - schedule(listRef.current); - }, [schedule]); - - const latest = useMemo(() => { - for (let i = toasts.length - 1; i >= 0; i--) { - if (!toasts[i].exiting) return toasts[i].id; - } - return undefined; - }, [toasts]); - - useEffect(() => { - latestRef.current = latest; - setActiveId(latest); - }, [latest]); - - const getHandlers = useCallback((toastId: string) => { - let cached = handlersCache.current.get(toastId); - if (cached) return cached; - - cached = { - enter: ((e) => { - setActiveId((prev) => (prev === toastId ? prev : toastId)); - handleMouseEnterRef.current?.(e); - }) as MouseEventHandler, - leave: ((e) => { - setActiveId((prev) => - prev === latestRef.current ? prev : latestRef.current, - ); - handleMouseLeaveRef.current?.(e); - }) as MouseEventHandler, - dismiss: () => dismissToast(toastId), - }; - - handlersCache.current.set(toastId, cached); - return cached; - }, []); - - const getViewportStyle = useCallback( - (pos: SileoPosition): CSSProperties | undefined => { - if (offset === undefined) return undefined; - - const o = - typeof offset === "object" - ? offset - : { top: offset, right: offset, bottom: offset, left: offset }; - - const s: CSSProperties = {}; - const px = (v: SileoOffsetValue) => - typeof v === "number" ? `${v}px` : v; - - if (pos.startsWith("top") && o.top) s.top = px(o.top); - if (pos.startsWith("bottom") && o.bottom) s.bottom = px(o.bottom); - if (pos.endsWith("left") && o.left) s.left = px(o.left); - if (pos.endsWith("right") && o.right) s.right = px(o.right); - - return s; - }, - [offset], - ); - - const byPosition = useMemo(() => { - const map = {} as Partial>; - for (const t of toasts) { - const pos = t.position ?? position; - const arr = map[pos]; - if (arr) { - arr.push(t); - } else { - map[pos] = [t]; - } - } - return map; - }, [toasts, position]); - - return ( - <> - {children} - {SILEO_POSITIONS.map((pos) => { - const items = byPosition[pos]; - if (!items?.length) return null; - - const pill = pillAlign(pos); - const expand = expandDir(pos); - - return ( -
- {items.map((item) => { - const h = getHandlers(item.id); - return ( - - ); - })} -
- ); - })} - - ); -} diff --git a/src/vue/icons.ts b/src/vue/icons.ts new file mode 100644 index 0000000..5194001 --- /dev/null +++ b/src/vue/icons.ts @@ -0,0 +1,63 @@ +import { type VNode, h } from "vue"; + +function Icon( + title: string, + children: VNode[], + extraProps?: Record, +): VNode { + return h( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: "16", + height: "16", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + ...extraProps, + }, + [h("title", null, title), ...children], + ); +} + +export const ArrowRight = (): VNode => + Icon("Arrow Right", [ + h("path", { d: "M5 12h14" }), + h("path", { d: "m12 5 7 7-7 7" }), + ]); + +export const LifeBuoy = (): VNode => + Icon("Life Buoy", [ + h("circle", { cx: "12", cy: "12", r: "10" }), + h("path", { d: "m4.93 4.93 4.24 4.24" }), + h("path", { d: "m14.83 9.17 4.24-4.24" }), + h("path", { d: "m14.83 14.83 4.24 4.24" }), + h("path", { d: "m9.17 14.83-4.24 4.24" }), + h("circle", { cx: "12", cy: "12", r: "4" }), + ]); + +export const LoaderCircle = (props?: Record): VNode => + Icon( + "Loader Circle", + [h("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" })], + props, + ); + +export const X = (): VNode => + Icon("X", [ + h("path", { d: "M18 6 6 18" }), + h("path", { d: "m6 6 12 12" }), + ]); + +export const CircleAlert = (): VNode => + Icon("Circle Alert", [ + h("circle", { cx: "12", cy: "12", r: "10" }), + h("line", { x1: "12", x2: "12", y1: "8", y2: "12" }), + h("line", { x1: "12", x2: "12.01", y1: "16", y2: "16" }), + ]); + +export const Check = (): VNode => + Icon("Check", [h("path", { d: "M20 6 9 17l-5-5" })]); diff --git a/src/vue/index.ts b/src/vue/index.ts new file mode 100644 index 0000000..dabf7bb --- /dev/null +++ b/src/vue/index.ts @@ -0,0 +1,26 @@ +import "../styles.css"; + +import type { VNode } from "vue"; +import { sileo as _sileo } from "../core/store"; +import type { + SileoAPI, + SileoPromiseOptions as CoreSileoPromiseOptions, +} from "../core/store"; +import type { SileoOptions as CoreSileoOptions } from "../core/types"; + +export const sileo = _sileo as unknown as SileoAPI; + +export { Toaster } from "./toaster"; + +export type SileoOptions = CoreSileoOptions; +export type SileoPromiseOptions = CoreSileoPromiseOptions< + T, + VNode +>; + +export type { + SileoButton, + SileoPosition, + SileoState, + SileoStyles, +} from "../core/types"; diff --git a/src/vue/sileo.ts b/src/vue/sileo.ts new file mode 100644 index 0000000..38d1818 --- /dev/null +++ b/src/vue/sileo.ts @@ -0,0 +1,737 @@ +import { + type PropType, + type VNode, + type VNodeChild, + computed, + defineComponent, + h, + nextTick, + onMounted, + onUnmounted, + ref, + watch, +} from "vue"; +import type { SileoButton, SileoState, SileoStyles } from "../core/types"; +import { + ArrowRight, + Check, + CircleAlert, + LifeBuoy, + LoaderCircle, + X, +} from "./icons"; + +/* --------------------------------- Config --------------------------------- */ + +const HEIGHT = 40; +const WIDTH = 350; +const DEFAULT_ROUNDNESS = 18; +const BLUR_RATIO = 0.5; +const PILL_PADDING = 10; +const MIN_EXPAND_RATIO = 2.25; +const SWAP_COLLAPSE_MS = 200; +const HEADER_EXIT_MS = 150; + +type State = SileoState; + +interface View { + title?: string; + description?: unknown; + state: State; + icon?: unknown; + styles?: SileoStyles; + button?: SileoButton; + fill: string; +} + +/* ---------------------------------- Icons --------------------------------- */ + +const STATE_ICON: Record VNode> = { + success: () => Check(), + loading: () => + LoaderCircle({ "data-sileo-icon": "spin", "aria-hidden": "true" }), + error: () => X(), + warning: () => CircleAlert(), + info: () => LifeBuoy(), + action: () => ArrowRight(), +}; + +/* ----------------------------- Gooey SVG Filter --------------------------- */ + +function GooeyDefs(filterId: string, blur: number): VNode { + return h("defs", null, [ + h( + "filter", + { + id: filterId, + x: "-20%", + y: "-20%", + width: "140%", + height: "140%", + "color-interpolation-filters": "sRGB", + }, + [ + h("feGaussianBlur", { + in: "SourceGraphic", + stdDeviation: blur, + result: "blur", + }), + h("feColorMatrix", { + in: "blur", + mode: "matrix", + values: "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10", + result: "goo", + }), + h("feComposite", { + in: "SourceGraphic", + in2: "goo", + operator: "atop", + }), + ] + ), + ]); +} + +/* ------------------------------- Component -------------------------------- */ + +export const Sileo = defineComponent({ + name: "Sileo", + props: { + id: { type: String, required: true }, + fill: { type: String, default: "#FFFFFF" }, + state: { type: String as PropType, default: "success" }, + title: { type: String, default: undefined }, + description: { default: undefined }, + position: { + type: String as PropType<"left" | "center" | "right">, + default: "left", + }, + expand: { + type: String as PropType<"top" | "bottom">, + default: "bottom", + }, + className: { type: String, default: undefined }, + icon: { default: undefined }, + styles: { type: Object as PropType, default: undefined }, + button: { type: Object as PropType, default: undefined }, + roundness: { type: Number, default: undefined }, + exiting: { type: Boolean, default: false }, + autoExpandDelayMs: { type: Number, default: undefined }, + autoCollapseDelayMs: { type: Number, default: undefined }, + canExpand: { type: Boolean, default: undefined }, + interruptKey: { type: String, default: undefined }, + refreshKey: { type: String, default: undefined }, + onMouseEnter: { + type: Function as PropType<(e: MouseEvent) => void>, + default: undefined, + }, + onMouseLeave: { + type: Function as PropType<(e: MouseEvent) => void>, + default: undefined, + }, + onDismiss: { + type: Function as PropType<() => void>, + default: undefined, + }, + }, + setup(props) { + const resolvedTitle = computed(() => props.title ?? props.state); + + const next = computed(() => ({ + title: resolvedTitle.value, + description: props.description, + state: props.state, + icon: props.icon, + styles: props.styles, + button: props.button, + fill: props.fill, + })); + + /* ------------------------------- State -------------------------------- */ + + const view = ref({ ...next.value }); + const applied = ref(props.refreshKey); + const isExpanded = ref(false); + const ready = ref(false); + const pillWidth = ref(0); + const contentHeight = ref(0); + + const hasDesc = computed( + () => Boolean(view.value.description) || Boolean(view.value.button) + ); + const isLoading = computed(() => view.value.state === "loading"); + const open = computed( + () => hasDesc.value && isExpanded.value && !isLoading.value + ); + const allowExpand = computed(() => { + if (isLoading.value) return false; + return ( + props.canExpand ?? + (!props.interruptKey || props.interruptKey === props.id) + ); + }); + + const headerKey = computed(() => `${view.value.state}-${view.value.title}`); + const filterId = computed(() => `sileo-gooey-${props.id}`); + const resolvedRoundness = computed(() => + Math.max(0, props.roundness ?? DEFAULT_ROUNDNESS) + ); + const blur = computed(() => resolvedRoundness.value * BLUR_RATIO); + + /* ------------------------------- Refs --------------------------------- */ + + const headerRef = ref(null); + const contentRef = ref(null); + const innerRef = ref(null); + const buttonRef = ref(null); + + let headerExitTimer: number | null = null; + let autoExpandTimer: number | null = null; + let autoCollapseTimer: number | null = null; + let swapTimer: number | null = null; + let lastRefreshKey = props.refreshKey; + let pendingView: { key?: string; payload: View } | null = null; + let headerPadding: number | null = null; + let frozenExpanded = HEIGHT * MIN_EXPAND_RATIO; + let pointerStartY: number | null = null; + + /* ----------------------------- Header Layer --------------------------- */ + + const headerLayer = ref<{ + current: { key: string; view: View }; + prev: { key: string; view: View } | null; + }>({ + current: { key: headerKey.value, view: { ...view.value } }, + prev: null, + }); + + /* ----------------------------- Derived Values ------------------------- */ + + const minExpanded = HEIGHT * MIN_EXPAND_RATIO; + + const rawExpanded = computed(() => { + if (!hasDesc.value) return minExpanded; + return Math.max(minExpanded, HEIGHT + contentHeight.value); + }); + + const expanded = computed(() => { + if (open.value) { + frozenExpanded = rawExpanded.value; + return rawExpanded.value; + } + return frozenExpanded; + }); + + const svgHeight = computed(() => + hasDesc.value ? Math.max(expanded.value, minExpanded) : HEIGHT + ); + const expandedContent = computed(() => + Math.max(0, expanded.value - HEIGHT) + ); + const resolvedPillWidth = computed(() => + Math.max(pillWidth.value || HEIGHT, HEIGHT) + ); + const pillHeight = computed(() => HEIGHT + blur.value * 3); + const pillX = computed(() => { + if (props.position === "right") return WIDTH - resolvedPillWidth.value; + if (props.position === "center") + return (WIDTH - resolvedPillWidth.value) / 2; + return 0; + }); + + const rootStyle = computed(() => ({ + "--_h": `${open.value ? expanded.value : HEIGHT}px`, + "--_pw": `${resolvedPillWidth.value}px`, + "--_px": `${pillX.value}px`, + "--_sy": `${open.value ? 1 : HEIGHT / pillHeight.value}`, + "--_ph": `${pillHeight.value}px`, + "--_by": `${open.value ? 1 : 0}`, + "--_ht": `translateY(${open.value ? (props.expand === "bottom" ? 3 : -3) : 0}px) scale(${open.value ? 0.9 : 1})`, + "--_co": `${open.value ? 1 : 0}`, + })); + + /* ----------------------------- Measurements --------------------------- */ + + let pillRo: ResizeObserver | null = null; + let pillRafId = 0; + let contentRo: ResizeObserver | null = null; + let contentRafId = 0; + + const measurePillWidth = () => { + const el = innerRef.value; + const header = headerRef.value; + if (!el || !header) return; + if (headerPadding === null) { + const cs = getComputedStyle(header); + headerPadding = + parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); + } + const w = el.scrollWidth + headerPadding + PILL_PADDING; + if (w > PILL_PADDING) { + pillWidth.value = w; + } + }; + + const setupPillObserver = () => { + if (pillRo) { + cancelAnimationFrame(pillRafId); + pillRo.disconnect(); + } + const el = innerRef.value; + if (!el) return; + measurePillWidth(); + pillRo = new ResizeObserver(() => { + cancelAnimationFrame(pillRafId); + pillRafId = requestAnimationFrame(measurePillWidth); + }); + pillRo.observe(el); + }; + + const measureContentHeight = () => { + const el = contentRef.value; + if (!el) return; + contentHeight.value = el.scrollHeight; + }; + + const setupContentObserver = () => { + if (contentRo) { + cancelAnimationFrame(contentRafId); + contentRo.disconnect(); + contentRo = null; + } + if (!hasDesc.value) { + contentHeight.value = 0; + return; + } + const el = contentRef.value; + if (!el) return; + measureContentHeight(); + contentRo = new ResizeObserver(() => { + cancelAnimationFrame(contentRafId); + contentRafId = requestAnimationFrame(measureContentHeight); + }); + contentRo.observe(el); + }; + + /* ----------------------------- Header Layer Sync ---------------------- */ + + watch( + [headerKey, () => view.value], + ([newKey]) => { + const state = headerLayer.value; + if (state.current.key === newKey) { + if (state.current.view === view.value) return; + headerLayer.value = { + ...state, + current: { key: newKey, view: view.value }, + }; + } else { + headerLayer.value = { + prev: state.current, + current: { key: newKey, view: view.value }, + }; + } + }, + { flush: "sync" } + ); + + watch( + () => headerLayer.value.prev, + (prev) => { + if (!prev) return; + if (headerExitTimer) clearTimeout(headerExitTimer); + headerExitTimer = window.setTimeout(() => { + headerExitTimer = null; + headerLayer.value = { ...headerLayer.value, prev: null }; + }, HEADER_EXIT_MS); + } + ); + + watch( + () => headerLayer.value.current.key, + () => nextTick(setupPillObserver), + { flush: "post" } + ); + + watch(hasDesc, () => nextTick(setupContentObserver), { flush: "post" }); + + /* ----------------------------- Refresh Logic -------------------------- */ + + watch( + [open, () => props.refreshKey, next], + ([openVal, refreshKeyVal, nextVal]) => { + if (refreshKeyVal === undefined) { + view.value = nextVal as View; + applied.value = undefined; + pendingView = null; + lastRefreshKey = refreshKeyVal; + return; + } + + if (lastRefreshKey === refreshKeyVal) return; + lastRefreshKey = refreshKeyVal as string; + + if (swapTimer) { + clearTimeout(swapTimer); + swapTimer = null; + } + + if (openVal) { + pendingView = { + key: refreshKeyVal as string, + payload: nextVal as View, + }; + isExpanded.value = false; + swapTimer = window.setTimeout(() => { + swapTimer = null; + if (!pendingView) return; + view.value = pendingView.payload; + applied.value = pendingView.key; + pendingView = null; + }, SWAP_COLLAPSE_MS); + } else { + pendingView = null; + view.value = nextVal as View; + applied.value = refreshKeyVal as string; + } + } + ); + + /* ----------------------------- Auto Expand/Collapse ------------------- */ + + watch( + [ + () => props.autoCollapseDelayMs, + () => props.autoExpandDelayMs, + hasDesc, + allowExpand, + () => props.exiting, + applied, + ], + () => { + if (autoExpandTimer) clearTimeout(autoExpandTimer); + if (autoCollapseTimer) clearTimeout(autoCollapseTimer); + + if (!hasDesc.value || !window) return; + + if (props.exiting || !allowExpand.value) { + isExpanded.value = false; + return; + } + + if ( + props.autoExpandDelayMs == null && + props.autoCollapseDelayMs == null + ) + return; + + const expandDelay = props.autoExpandDelayMs ?? 0; + const collapseDelay = props.autoCollapseDelayMs ?? 0; + + if (expandDelay > 0) { + autoExpandTimer = window.setTimeout(() => { + isExpanded.value = true; + }, expandDelay); + } else { + isExpanded.value = true; + } + + if (collapseDelay > 0) { + autoCollapseTimer = window.setTimeout(() => { + isExpanded.value = false; + }, collapseDelay); + } + }, + { immediate: true } + ); + + /* ------------------------------- Swipe -------------------------------- */ + + const SWIPE_DISMISS = 30; + const SWIPE_MAX = 20; + + const setupSwipe = () => { + const el = buttonRef.value; + if (!el) return; + + const onMove = (e: PointerEvent) => { + if (pointerStartY === null) return; + const dy = e.clientY - pointerStartY; + const sign = dy > 0 ? 1 : -1; + const clamped = Math.min(Math.abs(dy), SWIPE_MAX) * sign; + el.style.transform = `translateY(${clamped}px)`; + }; + + const onUp = (e: PointerEvent) => { + if (pointerStartY === null) return; + const dy = e.clientY - pointerStartY; + pointerStartY = null; + el.style.transform = ""; + if (Math.abs(dy) > SWIPE_DISMISS) { + props.onDismiss?.(); + } + }; + + el.addEventListener("pointermove", onMove, { passive: true }); + el.addEventListener("pointerup", onUp, { passive: true }); + }; + + /* ------------------------------- Handlers ----------------------------- */ + + const handleEnter = (e: MouseEvent) => { + props.onMouseEnter?.(e); + if (hasDesc.value) isExpanded.value = true; + }; + + const handleLeave = (e: MouseEvent) => { + props.onMouseLeave?.(e); + isExpanded.value = false; + }; + + const handleTransitionEnd = (e: TransitionEvent) => { + if (e.propertyName !== "height" && e.propertyName !== "transform") return; + if (open.value) return; + if (!pendingView) return; + if (swapTimer) { + clearTimeout(swapTimer); + swapTimer = null; + } + view.value = pendingView.payload; + applied.value = pendingView.key; + pendingView = null; + }; + + const handlePointerDown = (e: PointerEvent) => { + if (props.exiting || !props.onDismiss) return; + const target = e.target as HTMLElement; + if (target.closest("[data-sileo-button]")) return; + pointerStartY = e.clientY; + (e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId); + }; + + /* ------------------------------- Lifecycle ---------------------------- */ + + onMounted(() => { + requestAnimationFrame(() => { + ready.value = true; + }); + setupPillObserver(); + setupContentObserver(); + setupSwipe(); + }); + + onUnmounted(() => { + if (pillRo) { + cancelAnimationFrame(pillRafId); + pillRo.disconnect(); + } + if (contentRo) { + cancelAnimationFrame(contentRafId); + contentRo.disconnect(); + } + if (headerExitTimer) clearTimeout(headerExitTimer); + if (autoExpandTimer) clearTimeout(autoExpandTimer); + if (autoCollapseTimer) clearTimeout(autoCollapseTimer); + if (swapTimer) clearTimeout(swapTimer); + }); + + /* -------------------------------- Render ------------------------------ */ + + return () => { + const viewVal = view.value; + const headerLayerVal = headerLayer.value; + const openVal = open.value; + + const currentIcon = (headerLayerVal.current.view.icon ?? + STATE_ICON[headerLayerVal.current.view.state]()) as VNodeChild; + + const headerContent: VNode[] = [ + h( + "div", + { + ref: innerRef, + key: headerLayerVal.current.key, + "data-sileo-header-inner": "", + "data-layer": "current", + }, + [ + h( + "div", + { + "data-sileo-badge": "", + "data-state": headerLayerVal.current.view.state, + class: headerLayerVal.current.view.styles?.badge, + }, + [currentIcon] + ), + h( + "span", + { + "data-sileo-title": "", + "data-state": headerLayerVal.current.view.state, + class: headerLayerVal.current.view.styles?.title, + }, + headerLayerVal.current.view.title + ), + ] + ), + ]; + + if (headerLayerVal.prev) { + const prevIcon = (headerLayerVal.prev.view.icon ?? + STATE_ICON[headerLayerVal.prev.view.state]()) as VNodeChild; + + headerContent.push( + h( + "div", + { + key: headerLayerVal.prev.key, + "data-sileo-header-inner": "", + "data-layer": "prev", + "data-exiting": "true", + }, + [ + h( + "div", + { + "data-sileo-badge": "", + "data-state": headerLayerVal.prev.view.state, + class: headerLayerVal.prev.view.styles?.badge, + }, + [prevIcon] + ), + h( + "span", + { + "data-sileo-title": "", + "data-state": headerLayerVal.prev.view.state, + class: headerLayerVal.prev.view.styles?.title, + }, + headerLayerVal.prev.view.title + ), + ] + ) + ); + } + + const children: VNode[] = [ + h("div", { "data-sileo-canvas": "", "data-edge": props.expand }, [ + h( + "svg", + { + "data-sileo-svg": "", + width: WIDTH, + height: svgHeight.value, + viewBox: `0 0 ${WIDTH} ${svgHeight.value}`, + }, + [ + h("title", null, "Sileo Notification"), + GooeyDefs(filterId.value, blur.value), + h("g", { filter: `url(#${filterId.value})` }, [ + h("rect", { + "data-sileo-pill": "", + x: pillX.value, + rx: resolvedRoundness.value, + ry: resolvedRoundness.value, + fill: viewVal.fill, + }), + h("rect", { + "data-sileo-body": "", + y: HEIGHT, + width: WIDTH, + height: expandedContent.value, + rx: resolvedRoundness.value, + ry: resolvedRoundness.value, + fill: viewVal.fill, + }), + ]), + ] + ), + ]), + + h( + "div", + { + ref: headerRef, + "data-sileo-header": "", + "data-edge": props.expand, + }, + [h("div", { "data-sileo-header-stack": "" }, headerContent)] + ), + ]; + + if (hasDesc.value) { + const descChildren: VNodeChild[] = []; + + if (viewVal.description != null) { + descChildren.push(viewVal.description as VNodeChild); + } + + if (viewVal.button) { + descChildren.push( + h( + "a", + { + href: "#", + type: "button", + "data-sileo-button": "", + "data-state": viewVal.state, + class: viewVal.styles?.button, + onClick: (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + viewVal.button?.onClick(); + }, + }, + viewVal.button.title + ) + ); + } + + children.push( + h( + "div", + { + "data-sileo-content": "", + "data-edge": props.expand, + "data-visible": openVal, + }, + [ + h( + "div", + { + ref: contentRef, + "data-sileo-description": "", + class: viewVal.styles?.description, + }, + descChildren + ), + ] + ) + ); + } + + return h( + "button", + { + ref: buttonRef, + type: "button", + "data-sileo-toast": "", + "data-ready": ready.value, + "data-expanded": openVal, + "data-exiting": props.exiting, + "data-edge": props.expand, + "data-position": props.position, + "data-state": viewVal.state, + class: props.className, + style: rootStyle.value, + onMouseenter: handleEnter, + onMouseleave: handleLeave, + onTransitionend: handleTransitionEnd, + onPointerdown: handlePointerDown, + }, + children + ); + }; + }, +}); diff --git a/src/vue/toaster.ts b/src/vue/toaster.ts new file mode 100644 index 0000000..089a8eb --- /dev/null +++ b/src/vue/toaster.ts @@ -0,0 +1,273 @@ +import { + type PropType, + type VNode, + Fragment, + computed, + defineComponent, + h, + onMounted, + onUnmounted, + ref, + watch, +} from "vue"; +import { + DEFAULT_DURATION, + dismissToast, + store, + timeoutKey, + pillAlign, + expandDir, + type SileoItem, + type SileoListener, +} from "../core/store"; +import { + SILEO_POSITIONS, + type SileoOffsetConfig, + type SileoOffsetValue, + type SileoOptions, + type SileoPosition, +} from "../core/types"; +import { Sileo } from "./sileo"; + +/* ------------------------------ Toaster Component ------------------------- */ + +export const Toaster = defineComponent({ + name: "SileoToaster", + props: { + position: { + type: String as PropType, + default: "top-right", + }, + offset: { + type: [Number, String, Object] as PropType< + SileoOffsetValue | SileoOffsetConfig + >, + default: undefined, + }, + options: { + type: Object as PropType>, + default: undefined, + }, + }, + setup(props, { slots }) { + const isMounted = ref(false); + const toasts = ref([]); + const activeId = ref(); + + let isHovering = false; + const timers = new Map(); + let latestId: string | undefined; + + const handlersCache = new Map< + string, + { + enter: (e: MouseEvent) => void; + leave: (e: MouseEvent) => void; + dismiss: () => void; + } + >(); + + /* ----------------------------- Store Sync ----------------------------- */ + + watch( + [() => props.position, () => props.options], + ([pos, opts]) => { + store.position = pos; + store.options = opts; + }, + { immediate: true } + ); + + const clearAllTimers = () => { + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + }; + + const schedule = (items: SileoItem[]) => { + if (isHovering) return; + for (const item of items) { + if (item.exiting) continue; + const key = timeoutKey(item); + if (timers.has(key)) continue; + const dur = item.duration ?? DEFAULT_DURATION; + if (dur === null || dur <= 0) continue; + timers.set( + key, + window.setTimeout(() => dismissToast(item.id), dur) + ); + } + }; + + const listener: SileoListener = (next) => { + toasts.value = next; + }; + + onMounted(() => { + isMounted.value = true; + toasts.value = [...store.toasts]; + store.listeners.add(listener); + }); + + onUnmounted(() => { + store.listeners.delete(listener); + clearAllTimers(); + }); + + /* ----------------------------- Timer Management ----------------------- */ + + watch(toasts, (items) => { + const toastKeys = new Set(items.map(timeoutKey)); + const toastIds = new Set(items.map((t) => t.id)); + for (const [key, timer] of timers) { + if (!toastKeys.has(key)) { + clearTimeout(timer); + timers.delete(key); + } + } + for (const id of handlersCache.keys()) { + if (!toastIds.has(id)) handlersCache.delete(id); + } + schedule(items); + }); + + /* ----------------------------- Active Tracking ------------------------ */ + + const latest = computed(() => { + for (let i = toasts.value.length - 1; i >= 0; i--) { + if (!toasts.value[i].exiting) return toasts.value[i].id; + } + return undefined; + }); + + watch( + latest, + (val) => { + latestId = val; + activeId.value = val; + }, + { immediate: true } + ); + + /* ----------------------------- Handlers ------------------------------- */ + + const getHandlers = (toastId: string) => { + let cached = handlersCache.get(toastId); + if (cached) return cached; + + cached = { + enter: () => { + activeId.value = toastId; + if (!isHovering) { + isHovering = true; + clearAllTimers(); + } + }, + leave: () => { + activeId.value = latestId; + if (isHovering) { + isHovering = false; + schedule(toasts.value); + } + }, + dismiss: () => dismissToast(toastId), + }; + + handlersCache.set(toastId, cached); + return cached; + }; + + const getViewportStyle = (pos: SileoPosition) => { + if (props.offset === undefined) return undefined; + const o = + typeof props.offset === "object" + ? (props.offset as SileoOffsetConfig) + : { + top: props.offset, + right: props.offset, + bottom: props.offset, + left: props.offset, + }; + const s: Record = {}; + const px = (v: SileoOffsetValue) => + typeof v === "number" ? `${v}px` : v; + if (pos.startsWith("top") && o.top) s.top = px(o.top); + if (pos.startsWith("bottom") && o.bottom) s.bottom = px(o.bottom); + if (pos.endsWith("left") && o.left) s.left = px(o.left); + if (pos.endsWith("right") && o.right) s.right = px(o.right); + return s; + }; + + /* ----------------------------- Grouping ------------------------------- */ + + const byPosition = computed(() => { + const map = {} as Partial>; + for (const t of toasts.value) { + const pos = (t.position ?? props.position) as SileoPosition; + const arr = map[pos]; + if (arr) { + arr.push(t); + } else { + map[pos] = [t]; + } + } + return map; + }); + + /* -------------------------------- Render ------------------------------ */ + + return () => { + if (!isMounted.value) return slots.default?.() ?? null; + + const viewports: VNode[] = []; + + for (const pos of SILEO_POSITIONS) { + const items = byPosition.value[pos]; + if (!items?.length) continue; + + const pill = pillAlign(pos); + const expand = expandDir(pos); + + viewports.push( + h( + "section", + { + key: pos, + "data-sileo-viewport": "", + "data-position": pos, + "aria-live": "polite", + style: getViewportStyle(pos), + }, + items.map((item) => { + const handlers = getHandlers(item.id); + return h(Sileo, { + key: item.id, + id: item.id, + state: item.state, + title: item.title, + description: item.description, + position: pill, + expand, + icon: item.icon, + fill: item.fill, + styles: item.styles, + button: item.button, + roundness: item.roundness, + exiting: item.exiting, + autoExpandDelayMs: item.autoExpandDelayMs, + autoCollapseDelayMs: item.autoCollapseDelayMs, + refreshKey: item.instanceId, + canExpand: + activeId.value === undefined || activeId.value === item.id, + onMouseEnter: handlers.enter, + onMouseLeave: handlers.leave, + onDismiss: handlers.dismiss, + }); + }) + ) + ); + } + + return h(Fragment, [slots.default?.(), ...viewports]); + }; + }, +});