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]);
+ };
+ },
+});