diff --git a/.gitignore b/.gitignore index 5ae1430..6ac6022 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .DS_Store node_modules/ dist/ + +# Packages package-lock.json +pnpm-lock.yaml \ No newline at end of file diff --git a/example/index.html b/example/index.html index cde335d..fa06b74 100644 --- a/example/index.html +++ b/example/index.html @@ -1,33 +1,78 @@ - - sonner-js example - - - + - - - - - - - - - - + } + + function loadingLots() { + for (let i = 0; i < 10; i++) { + const promise = new Promise((resolve, reject) => { + setTimeout(() => { + if (fail) reject(new Error('OH NO!')); + resolve(Date.now()) + }, 1_000); + }); + Sonner.promise(promise, { + loading: `#${i}: Waiting for 1s...`, + success: (data) => `The current time is now: ${data}`, + error: 'Oops! Failed :c', + duration: -1 + }); + } + } + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index 5c69c24..c8daa8f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dist" ], "scripts": { - "prepare": "uglifyjs src/sonner.js -o dist/sonner.min.js && uglifycss src/sonner.css --output dist/sonner.min.css" + "build": "webpack" }, "repository": { "type": "git", @@ -28,7 +28,13 @@ }, "homepage": "https://github.com/fernandojpps/sonner-js#readme", "devDependencies": { - "uglify-js": "^3.6.0", - "uglifycss": "^0.0.29" + "css-loader": "^7.1.2", + "css-minimizer-webpack-plugin": "^7.0.0", + "mini-css-extract-plugin": "^2.9.0", + "webpack": "^5.91.0", + "webpack-cli": "^5.1.4" + }, + "dependencies": { + "style-loader": "^4.0.0" } } diff --git a/src/sonner.css b/src/sonner.css index 5492038..56e9dd5 100644 --- a/src/sonner.css +++ b/src/sonner.css @@ -308,9 +308,9 @@ html[dir="rtl"], } [data-sonner-toast][data-expanded="false"][data-front="false"] { - --scale: var(--toasts-before) * 0.05 + 1; + --scale: var(--toasts-before) * 0.05; --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) - scale(calc(-1 * var(--scale))); + scale(calc(1 - var(--scale))); height: var(--front-toast-height); } diff --git a/src/sonner.js b/src/sonner.js index 4d708f5..95e3c7c 100644 --- a/src/sonner.js +++ b/src/sonner.js @@ -5,7 +5,7 @@ //////////////////////// // Constants //////////////////////// -const VISIBLE_TOASTS_AMOUNT = 3; +const VISIBLE_TOASTS_AMOUNT = 4; const VIEWPORT_OFFSET = "32px"; const TOAST_LIFETIME = 4000; const TOAST_WIDTH = 356; @@ -50,59 +50,98 @@ window.Sonner = { * @param {string} msg - The message to display in the toast. * @returns {void} */ - success(msg) { - Sonner.show(msg, { type: "success" }); + success(msg, opts = {}) { + return Sonner.show(msg, { icon: 'success', type: "success", ...opts }); }, /** * Shows a new error toast with a specific message. * @param {string} msg - The message to display in the toast. * @returns {void} */ - error(msg) { - Sonner.show(msg, { type: "error" }); + error(msg, opts = {}) { + return Sonner.show(msg, { icon: 'error', type: "error", ...opts }); }, /** * Shows a new info toast with a specific message. * @param {string} msg - The message to display in the toast. * @returns {void} */ - info(msg) { - Sonner.show(msg, { type: "info" }); + info(msg, opts = {}) { + return Sonner.show(msg, { icon: 'info', type: "info", ...opts }); }, /** * Shows a new warning toast with a specific message. * @param {string} msg - The message to display in the toast. * @returns {void} */ - warning(msg) { - Sonner.show(msg, { type: "warning" }); + warning(msg, opts = {}) { + return Sonner.show(msg, { icon: 'warning', type: "warning", ...opts }); }, + /** + * Shows a new loading toast with a specific message. + * The toast by default will not disapear until dismissed. + * @param {string} msg - The message to display in the toast. + * @returns {object} + */ + loading(msg, opts = {}) { + return Sonner.show(msg, { icon: 'loading', type: 'loading', duration: -1, ...opts }); + }, + /** + * Shows a promise loading toast + * @template T promise data type + * @param {Promise} promise + * @param {Object} opts options + * @param {string} opts.loading message to display while loading + * @param {string|(data : T) => string} opts.success function callback / message to show when loaded + * @param {string|(data : Error) => string} opts.success function callback / message to show when errored + */ + promise(promise, opts = {}) { + const toast = Sonner.loading(opts.loading ?? 'Loading...', { ...opts, duration: -1 }); + promise + .then(value => { + // Update the message and start the timeout. We set everything first so the bind has a opportunity to change it if needed. + toast.setIcon('success').setType('success').setDuration(opts.duration ?? TOAST_LIFETIME); + const msg = typeof opts.success === 'string' ? opts.success : opts.success.bind(toast)(value); + if (msg !== undefined) toast.setTitle(msg); + return value; + }) + .catch(reason => { + toast.setIcon('error').setType('error').setDuration(opts.duration ?? TOAST_LIFETIME); + const msg = typeof opts.error === 'string' ? opts.error : opts.error.bind(toast)(reason); + if (msg !== undefined) toast.setTitle(msg); + throw reason; + }); + + return promise; + }, + /** * Shows a new toast with a specific message, description, and type. * @param {string} msg - The message to display in the toast. * @param {Object} options - An object with the following properties: * @param {string} options.type - The type of the toast. The type can be one of the following values: "success", "error", "info", "warning", or "neutral". * @param {string} options.description - The description to display in the toast. - * @returns {void} + * @returns {object} */ - show(msg, { description, type } = {}) { + show(msg, opts = {}) { const list = document.getElementById("sonner-toaster-list"); - const { toast, id } = renderToast(list, msg, { description, type }); + const { toast, id } = renderToast(list, msg, opts); // Wait for the toast to be mounted before registering swipe events - window.setTimeout(function () { - const el = list.children[0]; - const height = el.getBoundingClientRect().height; + //window.setTimeout(function () { + const el = list.children[0]; + const height = el.getBoundingClientRect().height; - el.setAttribute("data-mounted", "true"); - el.setAttribute("data-initial-height", height); - el.style.setProperty("--initial-height", `${height}px`); - list.style.setProperty("--front-toast-height", `${height}px`); + el.setAttribute("data-mounted", "true"); + el.setAttribute("data-initial-height", height); + el.style.setProperty("--initial-height", `${height}px`); + list.style.setProperty("--front-toast-height", `${height}px`); - registerSwipe(id); - refreshProperties(); - registerRemoveTimeout(el); - }, 16); + registerSwipe(id); + refreshProperties(); + toast.setDuration(opts.duration ?? TOAST_LIFETIME); + //}, 16); + return toast; }, /** * Removes an element with a specific id from the DOM after a delay. @@ -115,6 +154,7 @@ window.Sonner = { remove(id) { const el = document.querySelector(`[data-id="${id}"]`); if (!el) return; + el.setAttribute("data-removed", "true"); refreshProperties(); @@ -132,7 +172,7 @@ window.Sonner = { // Assets //////////////////////// -const getAsset = (type) => { +const getIcon = (type) => { switch (type) { case "success": return SuccessIcon; @@ -146,13 +186,25 @@ const getAsset = (type) => { case "error": return ErrorIcon; + case 'loading': + return Loader; + default: - return null; + return undefined; } }; const bars = Array(12).fill(0); +const Loader = ` +
+
+ ${bars.map((_, i) => `
`).join('\n')} +
+
+`; + + const SuccessIcon = ` - ${ - list.getAttribute("data-close-button") === "true" - ? ` ` - : "" - } - ${ - asset - ? ` -
- ${getAsset(type)} -
-` - : "" - } + : "" + } + ${asset + ? `
${asset}
` + : `
` + }
${msg}
- ${ - description - ? `
${description}
` - : "" - } + ${opts.description + ? `
${opts.description}
` + : "" + }
`; - return { toast, id }; + + return { + id, + toast: { + target: document.querySelector(`[data-id="${id}"]`), + setTitle: function (msg, raw = false) { + const title = document.querySelector(`[data-sonner-toast][data-id=${id}] [data-title]`); + if (raw) title.innerHTML = msg; + else title.textContent = msg; + return this; + }, + setDescription: function (description, raw = false) { + let desc = document.querySelector(`[data-sonner-toast][data-id=${id}] [data-description]`); + if (description == null) { + desc?.remove(); + return this; + } + + if (!desc) { + desc = document.createElement('div'); + desc.setAttribute('data-description', ''); + document.querySelector(`[data-sonner-toast][data-id=${id}] [data-content]`).appendChild(desc); + } + + if (raw) desc.innerHTML = description; + else desc.textContent = description; + return this; + }, + setIcon: function (icon) { + const ico = getIcon(icon) ?? ''; + document.querySelector(`[data-sonner-toast][data-id=${id}] [data-icon]`).innerHTML = ico; + return this; + }, + setDuration: function (duration) { + this.target.setAttribute('data-duration', duration); + registerRemoveTimeout(this.target); + return this; + }, + setType: function (type) { + this.target.setAttribute('data-type', type); + return this; + }, + dismiss: function () { + Sonner.remove(this.target.getAttribute("data-id")) + return this; + } + } + }; } /** @@ -325,12 +419,25 @@ function renderToast(list, msg, { type, description }) { * The function sets a new timeout to remove the element from its parent after a delay. * The timeout ensures that all CSS transitions complete before the element is removed. * @param {Element} el - The element to register the remove timeout for. + * @param {number} lifetime - How long the toast will last for * @returns {void} */ function registerRemoveTimeout(el) { - const tid = window.setTimeout(function () { + if (!el.getAttribute("data-id")) + throw new Error('invalid target for removal'); + + const lifetime = el.getAttribute('data-duration') ?? TOAST_LIFETIME; + if (lifetime < 0) + return; + + // Clear previous duration + if (el.getAttribute("data-remove-tid")) + window.clearTimeout(el.getAttribute("data-remove-tid")); + + // Set new timeout + const tid = window.setTimeout(() => { Sonner.remove(el.getAttribute("data-id")); - }, TOAST_LIFETIME); + }, lifetime); el.setAttribute("data-remove-tid", tid); } diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..10554cd --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,32 @@ +const path = require('path'); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); + +module.exports = { + plugins: [new MiniCssExtractPlugin({ + filename: "sonner.min.css", + })], + entry: [ + './src/sonner.js', + './src/sonner.css', + ], + output: { + filename: 'sonner.min.js', + path: path.resolve(__dirname, 'dist'), + }, + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + optimization: { + minimizer: [ + // For webpack@5 you can use the `...` syntax to extend existing minimizers (i.e. `terser-webpack-plugin`), uncomment the next line + `...`, + new CssMinimizerPlugin(), + ], + }, +}; \ No newline at end of file