├── .stubs.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── toaster.php ├── resources ├── js │ ├── config.js │ ├── hub.js │ ├── index.js │ ├── toast.js │ ├── toaster.js │ └── uuid41.js └── views │ └── hub.blade.php └── src ├── AccessibleCollector.php ├── Alignment.php ├── Assertable.php ├── Collector.php ├── Duration.php ├── LivewireRelay.php ├── Message.php ├── PendingToast.php ├── Position.php ├── QueuingCollector.php ├── SessionRelay.php ├── TestableCollector.php ├── Toast.php ├── ToastBuilder.php ├── ToastType.php ├── Toastable.php ├── ToastableMacros.php ├── Toaster.php ├── ToasterConfig.php ├── ToasterHub.php ├── ToasterServiceProvider.php └── TranslatingCollector.php /.stubs.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Toaster Banner

2 | 3 | # Beautiful toast notifications for Livewire 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/masmerise/livewire-toaster.svg?style=flat-square)](https://packagist.org/packages/masmerise/livewire-toaster) 6 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/masmerise/livewire-toaster/test.yml?branch=master)](https://github.com/masmerise/livewire-toaster/actions?query=workflow%3A%22Automated+testing%22+branch%3Amaster) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/masmerise/livewire-toaster.svg?style=flat-square)](https://packagist.org/packages/masmerise/livewire-toaster) 8 | 9 | **Toaster** provides a seamless experience to display toast notifications in your Livewire powered Laravel apps. 10 | 11 | Unlike many other toast implementations that are available, Toaster makes it effortless to dispatch a toast notification 12 | from either a standard `Controller` or a Livewire `Component`. You don't have to think about "flashing" things to the 13 | session or "dispatching browser events" from your Livewire components. Just dispatch your toast and Toaster will route the message accordingly. 14 | 15 | ## Showcase 16 | 17 |

Toaster Demo

18 | 19 | ## Compatibility 20 | 21 | 22 | 23 |
LivewirePHPLaravel
24 | 25 | | | [LW2](https://laravel-livewire.com/docs/2.x) | [LW3](https://livewire.laravel.com/docs) | 26 | |-|-|-| 27 | | [1.x](https://github.com/masmerise/livewire-toaster/tree/1.3.0) | ✅ | ❌ | 28 | | 2.x | ❌ | ✅ | 29 | 30 | 31 | 32 | 33 | | | PHP 8.2 | PHP 8.3 | PHP 8.4 | 34 | |-|-|-|-| 35 | | 1.0 - ∞ | ✅ | ✅ | ✅ | 36 | 37 | 38 | 39 | | | L10 | L11 | L12 40 | |-|-|-|-| 41 | | 1.0 - 2.1 * | ✅ | ❌ | ❌ 42 | | 2.2 - ∞ | ❌ | ✅ | ✅ 43 | 44 |
45 | 46 | _* feature complete_ 47 | 48 | ## Contents 49 | 50 | **Looking for v1 docs?** [Click here](https://github.com/masmerise/livewire-toaster/tree/1.3.0). 51 | 52 | - [Installation](#installation) 53 | - [Preparing your template](#preparing-your-template) 54 | - [Configuring scripts](#configuring-scripts) 55 | - [Tailwind styles](#tailwind-styles) 56 | - [RTL support](#rtl-support) 57 | - [Usage](#usage) 58 | - [Sending toasts from the back-end](#sending-toasts-from-the-back-end) 59 | - [Sending toasts from the front-end](#sending-toasts-from-the-front-end) 60 | - [Automatic translation of messages](#automatic-translation-of-messages) 61 | - [Accessibility](#accessibility) 62 | - [Replacing similar toasts](#replacing-similar-toasts) 63 | - [Suppressing duplicate toasts](#suppressing-duplicate-toasts) 64 | - [Unit testing](#unit-testing) 65 | - [Extending behavior](#extending-behavior) 66 | - [View customization](#view-customization) 67 | - [Testing](#testing) 68 | - [Changelog](#changelog) 69 | - [Security](#security) 70 | - [Credits](#credits) 71 | - [License](#license) 72 | 73 | ## Installation 74 | 75 | You can install the package via [composer](https://getcomposer.org): 76 | 77 | ```bash 78 | composer require masmerise/livewire-toaster 79 | ``` 80 | 81 | You can publish the package's config file: 82 | 83 | ```bash 84 | php artisan vendor:publish --tag=toaster-config 85 | ``` 86 | 87 | This is the contents of the `toaster.php` config file: 88 | 89 | ```php 90 | return [ 91 | 92 | /** 93 | * Add an additional second for every 100th word of the toast messages. 94 | * 95 | * Supported: true | false 96 | */ 97 | 'accessibility' => true, 98 | 99 | /** 100 | * The vertical alignment of the toast container. 101 | * 102 | * Supported: "bottom", "middle" or "top" 103 | */ 104 | 'alignment' => 'bottom', 105 | 106 | /** 107 | * Allow users to close toast messages prematurely. 108 | * 109 | * Supported: true | false 110 | */ 111 | 'closeable' => true, 112 | 113 | /** 114 | * The on-screen duration of each toast. 115 | * 116 | * Minimum: 3000 (in milliseconds) 117 | */ 118 | 'duration' => 3000, 119 | 120 | /** 121 | * The horizontal position of each toast. 122 | * 123 | * Supported: "center", "left" or "right" 124 | */ 125 | 'position' => 'right', 126 | 127 | /** 128 | * New toasts immediately replace similar ones, ensuring only one toast of a kind is visible at any time. 129 | * Takes precedence over the "suppress" option. 130 | * 131 | * Supported: true | false 132 | */ 133 | 'replace' => false, 134 | 135 | /** 136 | * Prevent the display of duplicate toast messages. 137 | * 138 | * Supported: true | false 139 | */ 140 | 'suppress' => false, 141 | 142 | /** 143 | * Whether messages passed as translation keys should be translated automatically. 144 | * 145 | * Supported: true | false 146 | */ 147 | 'translate' => true, 148 | ]; 149 | ``` 150 | 151 | ### Preparing your template 152 | 153 | Next, you'll need to use the `` component in your master template: 154 | 155 | ```html 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | ``` 169 | 170 | ### Configuring scripts 171 | 172 | After that, you'll need to import `Toaster` at the top of your `resources/js/app.js` bundle to start listening to incoming toasts: 173 | 174 | ```js 175 | import './bootstrap'; 176 | import '../../vendor/masmerise/livewire-toaster/resources/js'; // 👈 177 | 178 | // other app stuff... 179 | ``` 180 | 181 | ### Tailwind styles 182 | 183 | > [!NOTE] 184 | > Skip this step if you're going to customize Toaster's default view. 185 | 186 | Toaster provides a minimal view that utilizes Tailwind CSS defaults. 187 | 188 | If the default toast appearances suffice your needs, you'll need to register it with Tailwind's purge list: 189 | 190 | ```js 191 | module.exports = { 192 | content: [ 193 | './resources/**/*.blade.php', 194 | './vendor/masmerise/livewire-toaster/resources/views/*.blade.php', // 👈 195 | ], 196 | } 197 | ``` 198 | 199 | Otherwise, please refer to [View customization](#view-customization). 200 | 201 | ### RTL support 202 | 203 | > [!NOTE] 204 | > **LTR** will be assumed regardless of whether you apply the `ltr` attribute or not. 205 | 206 | If your app makes use of an **RTL** language such as Arabic and Hebrew, don't forget to add the `rtl` attribute to the document root: 207 | 208 | ```html 209 | 210 | 211 | ... 212 | 213 | ``` 214 | 215 | This will make sure the UI elements (such as the close button) are flipped and the text is properly aligned. 216 | 217 | ## Usage 218 | 219 | ### Sending toasts from the back-end 220 | 221 | > [!NOTE] 222 | > Toaster supports the dispatch of multiple toasts at once, you are not limited to dispatching a single toast. 223 | 224 | #### Toaster 225 | 226 | The standard recommended way for dispatching toast messages is through the `Toaster` facade. 227 | 228 | ```php 229 | use Masmerise\Toaster\Toaster; 230 | 231 | final class RegistrationForm extends Component 232 | { 233 | public function submit(): void 234 | { 235 | $this->validate(); 236 | 237 | User::create($this->form); 238 | 239 | Toaster::success('User created!'); // 👈 240 | } 241 | } 242 | ``` 243 | 244 | If you need fine-grained control, you can always use the `PendingToast` class directly to which `Toaster` proxies its calls: 245 | 246 | ```php 247 | use Masmerise\Toaster\PendingToast; 248 | 249 | final class RegistrationForm extends Component 250 | { 251 | public function submit(): void 252 | { 253 | $this->validate(); 254 | 255 | $user = User::create($this->form); 256 | 257 | // 👇 258 | PendingToast::create() 259 | ->when($user->isAdmin(), 260 | fn (PendingToast $toast) => $toast->message('Admin created') 261 | ) 262 | ->unless($user->isAdmin(), 263 | fn (PendingToast $toast) => $toast->message('User created') 264 | ) 265 | ->success(); 266 | } 267 | } 268 | ``` 269 | 270 | #### Toastable 271 | 272 | You can make any class `Toastable` to dispatch toasts from: 273 | 274 | ```php 275 | use Masmerise\Toaster\Toastable; 276 | 277 | final class ProductListing extends Component 278 | { 279 | use Toastable; // 👈 280 | 281 | public function check(): void 282 | { 283 | $result = Product::query() 284 | ->tap(new Available()) 285 | ->count(); 286 | 287 | if ($result < 5) { 288 | $this->warning('The quantity on hand is critically low.'); // 👈 289 | } 290 | } 291 | } 292 | ``` 293 | 294 | #### Redirects 295 | 296 | Whenever you return a `RedirectResponse` from anywhere in your app, you can chain any of the `Toaster` methods 297 | to dispatch a toast message: 298 | 299 | ```php 300 | final class CompanyController extends Controller 301 | { 302 | /** @throws ValidationException */ 303 | public function store(Request $request): RedirectResponse 304 | { 305 | $validator = Validator::make($request->all(), [...]); 306 | 307 | if ($validator->fails()) { 308 | return Redirect::back() 309 | ->error('The form contains several errors'); // 👈 310 | } 311 | 312 | Company::create($validator->validate()); 313 | 314 | return Redirect::route('dashboard') 315 | ->info('Company created!'); // 👈 316 | } 317 | } 318 | ``` 319 | 320 | This is, of course, **not** limited to `Controller`s as you can also redirect in Livewire `Component`s. 321 | 322 | #### Dependency injection 323 | 324 | If you'd like to keep things "pure", you can also inject the `Collector` contract 325 | and use the `ToastBuilder` to dispatch your toasts: 326 | 327 | ```php 328 | use Masmerise\Toaster\Collector; 329 | use Masmerise\Toaster\ToasterConfig; 330 | use Masmerise\Toaster\ToastBuilder; 331 | 332 | final readonly class SendEmailVerifiedNotification 333 | { 334 | public function __construct( 335 | private ToasterConfig $config, 336 | private Collector $toasts, 337 | ) {} 338 | 339 | public function handle(Verified $event): void 340 | { 341 | $toast = ToastBuilder::create() 342 | ->duration($this->config->duration) 343 | ->success() 344 | ->message("Thank you, {$event->user->name}!") 345 | ->get(); 346 | 347 | $this->toasts->collect($toast); 348 | } 349 | } 350 | ``` 351 | 352 | ### Sending toasts from the front-end 353 | 354 | You can invoke the globally available `Toaster` instance to dispatch any toast message from anywhere: 355 | 356 | ```html 357 | 360 | ``` 361 | 362 | Available methods: `error`, `info`, `warning` & `success` 363 | 364 | ### Automatic translation of messages 365 | 366 | > [!NOTE] 367 | > The `translate` configuration value must be set to `true`. 368 | 369 | Instead of doing this: 370 | 371 | ```php 372 | Toaster::success( 373 | Lang::get('path.to.translation', ['replacement' => 'value']) 374 | ); 375 | ``` 376 | 377 | Toaster makes it possible to do this: 378 | 379 | ```php 380 | Toaster::success('path.to.translation', ['replacement' => 'value']); 381 | ``` 382 | 383 | You can mix and match without any problems: 384 | 385 | ```php 386 | Toaster::info('user.created', ['name' => $user->full_name]); 387 | Toaster::info('You now have full access!'); 388 | ``` 389 | 390 | You can do whatever you want, whenever you want. 391 | 392 | ### Accessibility 393 | 394 | > [!NOTE] 395 | > The `accessibility` configuration value must be set to `true`. 396 | 397 | Toaster will add an additional second to a toast's on-screen duration for every 100th word. 398 | This way, your users will have enough time to read toasts that are a tad larger than usual. 399 | 400 | So, if your base duration value is `3 seconds` and your toast contains 223 words, 401 | the total on-screen duration of the toast will be `3 + 2 = 5 seconds` 402 | 403 | ### Replacing similar toasts 404 | 405 | > [!NOTE] 406 | > The `replace` configuration value must be set to `true`. 407 | 408 | > [!WARNING] 409 | > Takes precedence over `suppress`. 410 | 411 | Toaster will dispose of any toast that is similar to the one being dispatched prior to displaying the new toast. 412 | A toast is considered similar if it has the same `duration`, `message`, and `type`. 413 | 414 | ### Suppressing duplicate toasts 415 | 416 | > [!NOTE] 417 | > The `suppress` configuration value must be set to `true`. 418 | 419 | Toaster will prevent the display of duplicate toast messages while another toast with the same message is still on-screen. 420 | A toast is considered a duplicate if it has the same `duration`, `message`, and `type`. 421 | 422 | ### Unit testing 423 | 424 | > [!NOTE] 425 | > If you make use of [automatic translation of messages](#automatic-translation-of-messages), you should assert whether the **translation keys** are passed along correctly instead of the human readable messages that are replaced by Laravel's translator. 426 | > Otherwise, your tests are going to fail as the messages are not translated during unit testing. 427 | 428 | Toaster provides a couple of testing capabilities in order for you to build a robust application: 429 | 430 | ```php 431 | use Masmerise\Toaster\Toaster; 432 | 433 | final class RegisterUserControllerTest extends TestCase 434 | { 435 | #[Test] 436 | public function users_can_register(): void 437 | { 438 | // Arrange 439 | Toaster::fake(); 440 | Toaster::assertNothingDispatched(); 441 | 442 | // Act 443 | $response = $this->post('users', [ ... ]); 444 | 445 | // Assert 446 | $response->assertRedirect('profile'); 447 | Toaster::assertDispatched('Welcome!'); 448 | } 449 | } 450 | ``` 451 | 452 | ### Extending behavior 453 | 454 | Imagine that you'd like to keep track of how many toasts are dispatched daily to display on an admin dashboard. 455 | First, create a new class that encapsulates this logic: 456 | 457 | ```php 458 | final readonly class DailyCountingCollector implements Collector 459 | { 460 | public function __construct(private Collector $next) {} 461 | 462 | public function collect(Toast $toast): void 463 | { 464 | // increment the counter on durable storage 465 | 466 | $this->next->collect($toast); 467 | } 468 | 469 | public function release(): array 470 | { 471 | return $this->next->release(); 472 | } 473 | } 474 | ``` 475 | 476 | After that, extend the behavior in your `AppServiceProvider`: 477 | 478 | ```php 479 | public function register(): void 480 | { 481 | $this->app->extend(Collector::class, 482 | static fn (Collector $next) => new DailyCountingCollector($next) 483 | ); 484 | } 485 | ``` 486 | 487 | That's it! 488 | 489 | ## View customization 490 | 491 | Even though the default toasts are pretty, they might not fit your design and you may want to customize them. 492 | 493 | You can do so by publishing Toaster's views: 494 | 495 | ```php 496 | php artisan vendor:publish --tag=toaster-views 497 | ``` 498 | 499 | The `hub.blade.php` view will be published to your application's `resources/views/vendor/toaster` directory. 500 | Feel free to modify anything to your liking. 501 | 502 | ### Available `viewData` 503 | 504 | - `$alignment` - can be used to align the toast container vertically depending on the configuration 505 | - `$closeable` - whether the close button should be rendered by the Blade component 506 | - `$config` - default configuration values, used by the Alpine component 507 | - `$position` - can be used to position the toasts depending on the configuration 508 | - `$toasts` - toasts that were flashed to the session by Toaster, used by the Alpine component 509 | 510 | > [!WARNING] 511 | > You **must** keep the `x-data` and `x-init` directives and you **must** keep using the `x-for` loop. 512 | > Otherwise, the Alpine component that powers Toaster will start malfunctioning. 513 | 514 | 515 | ## Testing 516 | 517 | ```bash 518 | composer test 519 | ``` 520 | 521 | ## Changelog 522 | 523 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 524 | 525 | ## Security 526 | 527 | If you discover any security related issues, please email support@muhammedsari.me instead of using the issue tracker. 528 | 529 | ## Credits 530 | 531 | - [Muhammed Sari](https://github.com/masmerise) 532 | - [Greg Korba](https://github.com/wirone) 533 | - [All Contributors](../../contributors) 534 | 535 | ## License 536 | 537 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 538 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "masmerise/livewire-toaster", 3 | "description": "Beautiful toast notifications for Laravel / Livewire.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "alert", 7 | "laravel", 8 | "livewire", 9 | "toast", 10 | "toaster" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Muhammed Sari", 15 | "email": "support@muhammedsari.me", 16 | "role": "Developer" 17 | } 18 | ], 19 | "homepage": "https://github.com/masmerise/livewire-toaster", 20 | "require": { 21 | "php": "~8.2 || ~8.3 || ~8.4", 22 | "illuminate/contracts": "^11.0 || ^12.0", 23 | "illuminate/http": "^11.0 || ^12.0", 24 | "illuminate/routing": "^11.0 || ^12.0", 25 | "illuminate/support": "^11.0 || ^12.0", 26 | "illuminate/view": "^11.0 || ^12.0", 27 | "livewire/livewire": "^3.0" 28 | }, 29 | "require-dev": { 30 | "larastan/larastan": "^2.0 || ^3.1", 31 | "laravel/pint": "^1.0", 32 | "orchestra/testbench": "^9.0 || ^10.0", 33 | "phpunit/phpunit": "^10.0 || ^11.5.3" 34 | }, 35 | "conflict": { 36 | "stevebauman/unfinalize": "*" 37 | }, 38 | "minimum-stability": "dev", 39 | "prefer-stable": true, 40 | "autoload": { 41 | "psr-4": { 42 | "Masmerise\\Toaster\\": "src" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Tests\\": "tests" 48 | } 49 | }, 50 | "config": { 51 | "sort-packages": true 52 | }, 53 | "extra": { 54 | "laravel": { 55 | "aliases": { 56 | "Toaster": "Masmerise\\Toaster\\Toaster" 57 | }, 58 | "providers": [ 59 | "Masmerise\\Toaster\\ToasterServiceProvider" 60 | ] 61 | } 62 | }, 63 | "scripts": { 64 | "format": "vendor/bin/pint", 65 | "larastan": "vendor/bin/phpstan analyse --memory-limit=2G", 66 | "test": "vendor/bin/phpunit", 67 | "verify": [ 68 | "@larastan", 69 | "@test" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/toaster.php: -------------------------------------------------------------------------------- 1 | true, 11 | 12 | /** 13 | * The vertical alignment of the toast container. 14 | * 15 | * Supported: "bottom", "middle" or "top" 16 | */ 17 | 'alignment' => 'bottom', 18 | 19 | /** 20 | * Allow users to close toast messages prematurely. 21 | * 22 | * Supported: true | false 23 | */ 24 | 'closeable' => true, 25 | 26 | /** 27 | * The on-screen duration of each toast. 28 | * 29 | * Minimum: 3000 (in milliseconds) 30 | */ 31 | 'duration' => 3000, 32 | 33 | /** 34 | * The horizontal position of each toast. 35 | * 36 | * Supported: "center", "left" or "right" 37 | */ 38 | 'position' => 'right', 39 | 40 | /** 41 | * New toasts immediately replace similar ones, ensuring only one toast of a kind is visible at any time. 42 | * Takes precedence over the "suppress" option. 43 | * 44 | * Supported: true | false 45 | */ 46 | 'replace' => false, 47 | 48 | /** 49 | * Prevent the display of duplicate toast messages. 50 | * 51 | * Supported: true | false 52 | */ 53 | 'suppress' => false, 54 | 55 | /** 56 | * Whether messages passed as translation keys should be translated automatically. 57 | * 58 | * Supported: true | false 59 | */ 60 | 'translate' => true, 61 | ]; 62 | -------------------------------------------------------------------------------- /resources/js/config.js: -------------------------------------------------------------------------------- 1 | class Alignment { 2 | static Top = 'top'; 3 | 4 | constructor(value) { 5 | this.value = value; 6 | } 7 | 8 | isTop() { 9 | return this.value === Alignment.Top; 10 | } 11 | } 12 | 13 | export class Config { 14 | constructor(alignment, duration, replace, suppress) { 15 | this.alignment = new Alignment(alignment); 16 | this.duration = duration; 17 | this.replace = replace; 18 | this.suppress = suppress; 19 | } 20 | 21 | static fromJson(data) { 22 | return new Config(data.alignment, data.duration, data.replace, data.suppress); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/js/hub.js: -------------------------------------------------------------------------------- 1 | import { Config } from './config'; 2 | import { Toast } from './toast'; 3 | 4 | export function Hub(Alpine) { 5 | Alpine.data('toasterHub', (initialToasts, config) => { 6 | config = Config.fromJson(config); 7 | 8 | return { 9 | _toasts: [], 10 | 11 | get toasts() { 12 | const toasts = this._toasts.filter(t => ! t.trashed); 13 | 14 | if (this._toasts.length && ! toasts.length) { 15 | this.$nextTick(() => { this._toasts = []; }); 16 | } 17 | 18 | return toasts; 19 | }, 20 | 21 | init() { 22 | document.addEventListener('toaster:received', event => { 23 | const toast = Toast.fromJson({ duration: config.duration, ...event.detail }); 24 | 25 | if (config.replace) { 26 | this.toasts.filter(t => t.equals(toast)).forEach(t => t.dispose()); 27 | } else if (config.suppress && this.toasts.some(t => t.equals(toast))) { 28 | return; 29 | } 30 | 31 | this.show(toast); 32 | }); 33 | 34 | initialToasts.map(Toast.fromJson).forEach(toast => this.show(toast)); 35 | }, 36 | 37 | show(toast) { 38 | toast = Alpine.reactive(toast); 39 | toast.runAfterDuration(toast => toast.dispose()); 40 | 41 | if (config.alignment.isTop()) { 42 | this._toasts.unshift(toast); 43 | } else { 44 | this._toasts.push(toast); 45 | } 46 | }, 47 | } 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /resources/js/index.js: -------------------------------------------------------------------------------- 1 | import { Hub } from './hub'; 2 | import * as Toaster from './toaster'; 3 | 4 | window.Toaster = Toaster; 5 | 6 | document.addEventListener('alpine:init', () => { 7 | window.Alpine.plugin(Hub); 8 | }); 9 | -------------------------------------------------------------------------------- /resources/js/toast.js: -------------------------------------------------------------------------------- 1 | import { uuid41 } from './uuid41'; 2 | 3 | export class Toast { 4 | constructor(duration, message, type) { 5 | this.$el = null; 6 | this.id = uuid41(); 7 | this.isVisible = false; 8 | this.duration = duration; 9 | this.message = message; 10 | this.timeout = null; 11 | this.trashed = false; 12 | this.type = type; 13 | } 14 | 15 | static fromJson(data) { 16 | return new Toast(data.duration, data.message, data.type); 17 | } 18 | 19 | dispose() { 20 | if (this.timeout) { 21 | clearTimeout(this.timeout); 22 | } 23 | 24 | this.isVisible = false; 25 | 26 | if (this.$el) { 27 | this.$el.addEventListener('transitioncancel', () => { this.trashed = true; }) 28 | this.$el.addEventListener('transitionend', () => { this.trashed = true; }) 29 | } 30 | } 31 | 32 | equals(other) { 33 | return this.duration === other.duration 34 | && this.message === other.message 35 | && this.type === other.type; 36 | } 37 | 38 | runAfterDuration(callback) { 39 | this.timeout = setTimeout(() => callback(this), this.duration); 40 | } 41 | 42 | select(config) { 43 | return config[this.type]; 44 | } 45 | 46 | show($el) { 47 | this.$el = $el; 48 | this.isVisible = true; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resources/js/toaster.js: -------------------------------------------------------------------------------- 1 | const event = (message, type) => { 2 | document.dispatchEvent(new CustomEvent('toaster:received', { detail: { message, type }})); 3 | }; 4 | 5 | const error = message => event(message, 'error'); 6 | const info = message => event(message, 'info'); 7 | const success = message => event(message, 'success'); 8 | const warning = message => event(message, 'warning'); 9 | 10 | export { error, info, success, warning } 11 | -------------------------------------------------------------------------------- /resources/js/uuid41.js: -------------------------------------------------------------------------------- 1 | export function uuid41() { 2 | let d = ''; 3 | 4 | while (d.length < 32) { 5 | d += Math.random().toString(16).substring(2); 6 | } 7 | 8 | const vr = ((Number.parseInt(d.substring(16, 1), 16) & 0x3) | 0x8).toString(16); 9 | 10 | return `${d.substring(0, 8)}-${d.substring(8, 4)}-4${d.substring(13, 3)}-${vr}${d.substring(17, 3)}-${d.substring(20, 12)}`; 11 | } 12 | -------------------------------------------------------------------------------- /resources/views/hub.blade.php: -------------------------------------------------------------------------------- 1 |
$alignment->is('bottom'), 4 | 'top-1/2 -translate-y-1/2' => $alignment->is('middle'), 5 | 'top-0' => $alignment->is('top'), 6 | 'items-start rtl:items-end' => $position->is('left'), 7 | 'items-center' => $position->is('center'), 8 | 'items-end rtl:items-start' => $position->is('right'), 9 | ])> 10 | 41 |
42 | -------------------------------------------------------------------------------- /src/AccessibleCollector.php: -------------------------------------------------------------------------------- 1 | message->value) / self::AMOUNT_OF_WORDS); 16 | $addend = $addend * self::ONE_SECOND; 17 | 18 | if ($addend > 0) { 19 | $toast = ToastBuilder::proto($toast)->duration($toast->duration->value + $addend)->get(); 20 | } 21 | 22 | $this->next->collect($toast); 23 | } 24 | 25 | public function release(): array 26 | { 27 | return $this->next->release(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Alignment.php: -------------------------------------------------------------------------------- 1 | value; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Collector.php: -------------------------------------------------------------------------------- 1 | value = $value; 21 | } 22 | 23 | public static function fromMillis(int $value): self 24 | { 25 | return new self($value); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/LivewireRelay.php: -------------------------------------------------------------------------------- 1 | get('redirect'); 24 | $isRedirectingUsingNavigate = store($component)->get('redirectUsingNavigate'); 25 | 26 | if ($isRedirecting && ! $isRedirectingUsingNavigate) { 27 | return; 28 | } 29 | 30 | if ($toasts = Toaster::release()) { 31 | foreach ($toasts as $toast) { 32 | $event = new Event(self::EVENT, $toast->toArray()); 33 | $ctx->pushEffect('dispatches', $event->serialize()); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | value = $value; 21 | $this->replace = $replace; 22 | } 23 | 24 | public static function fromString(string $value): self 25 | { 26 | return new self($value); 27 | } 28 | 29 | public static function fromTranslatable(string $value, array $replace = []): self 30 | { 31 | return new self($value, $replace); 32 | } 33 | 34 | public function equals(Message|string $other): bool 35 | { 36 | if ($other instanceof Message) { 37 | $other = $other->value; 38 | } 39 | 40 | return $other === $this->value; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/PendingToast.php: -------------------------------------------------------------------------------- 1 | builder = ToastBuilder::create()->duration($duration); 29 | } 30 | 31 | public static function create(): self 32 | { 33 | return new self(Toaster::config()->duration); 34 | } 35 | 36 | public function dispatch(): void 37 | { 38 | $toast = $this->builder->get(); 39 | 40 | Toaster::collect($toast); 41 | 42 | $this->dispatched = true; 43 | } 44 | 45 | public function __call(string $name, array $arguments): mixed 46 | { 47 | $result = $this->forwardCallTo($this->builder, $name, $arguments); 48 | 49 | if ($result instanceof ToastBuilder) { 50 | $this->builder = $result; 51 | 52 | return $this; 53 | } 54 | 55 | return $result; 56 | } 57 | 58 | public function __destruct() 59 | { 60 | if (! $this->dispatched) { 61 | $this->dispatch(); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Position.php: -------------------------------------------------------------------------------- 1 | toasts[] = $toast; 13 | } 14 | 15 | public function release(): array 16 | { 17 | $toasts = $this->toasts; 18 | $this->toasts = []; 19 | 20 | return $toasts; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/SessionRelay.php: -------------------------------------------------------------------------------- 1 | app->resolved(Collector::class)) { 22 | return $response; 23 | } 24 | 25 | if ($toasts = $this->app[Collector::class]->release()) { 26 | $this->app[Session::class]->put(self::NAME, $this->serialize($toasts)); 27 | } 28 | 29 | return $response; 30 | } 31 | 32 | private function serialize(array $toasts): array 33 | { 34 | return array_map(static fn (Toast $toast) => $toast->toArray(), $toasts); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/TestableCollector.php: -------------------------------------------------------------------------------- 1 | toasts[] = $toast; 14 | } 15 | 16 | public function release(): array 17 | { 18 | $toasts = $this->toasts; 19 | $this->toasts = []; 20 | 21 | return $toasts; 22 | } 23 | 24 | public function assertDispatched(string $message): void 25 | { 26 | $toasts = array_filter($this->toasts, static fn (Toast $toast) => $toast->message->equals($message)); 27 | 28 | PHPUnit::assertNotEmpty($toasts, "A toast with the message `{$message}` was not dispatched."); 29 | } 30 | 31 | public function assertNothingDispatched(): void 32 | { 33 | $count = count($this->toasts); 34 | 35 | PHPUnit::assertSame(0, $count, "{$count} unexpected toasts were dispatched."); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Toast.php: -------------------------------------------------------------------------------- 1 | $this->duration->value, 19 | 'message' => $this->message->value, 20 | 'type' => $this->type->value, 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ToastBuilder.php: -------------------------------------------------------------------------------- 1 | duration = $toast->duration; 27 | $builder->message = $toast->message; 28 | $builder->type = $toast->type; 29 | 30 | return $builder; 31 | } 32 | 33 | public function duration(int $milliseconds): self 34 | { 35 | return $this->modify('duration', Duration::fromMillis($milliseconds)); 36 | } 37 | 38 | public function error(): self 39 | { 40 | return $this->modify('type', ToastType::Error); 41 | } 42 | 43 | public function info(): self 44 | { 45 | return $this->modify('type', ToastType::Info); 46 | } 47 | 48 | public function message(string $message, array $replace = []): self 49 | { 50 | return $this->modify('message', Message::fromTranslatable($message, $replace)); 51 | } 52 | 53 | public function success(): self 54 | { 55 | return $this->modify('type', ToastType::Success); 56 | } 57 | 58 | public function type(string $type): self 59 | { 60 | return $this->modify('type', ToastType::from($type)); 61 | } 62 | 63 | public function warning(): self 64 | { 65 | return $this->modify('type', ToastType::Warning); 66 | } 67 | 68 | public function get(): Toast 69 | { 70 | if (! $this->duration instanceof Duration) { 71 | throw new UnexpectedValueException('You must provide a valid duration.'); 72 | } 73 | 74 | if (! $this->message instanceof Message) { 75 | throw new UnexpectedValueException('You must provide a valid message.'); 76 | } 77 | 78 | if (! $this->type instanceof ToastType) { 79 | throw new UnexpectedValueException('You must choose a valid type.'); 80 | } 81 | 82 | return new Toast($this->message, $this->duration, $this->type); 83 | } 84 | 85 | private function modify(string $property, mixed $value): self 86 | { 87 | $that = clone $this; 88 | $that->{$property} = $value; 89 | 90 | return $that; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/ToastType.php: -------------------------------------------------------------------------------- 1 | macro('error'); 13 | } 14 | 15 | protected function info(): Closure 16 | { 17 | return $this->macro('info'); 18 | } 19 | 20 | protected function success(): Closure 21 | { 22 | return $this->macro('success'); 23 | } 24 | 25 | protected function warning(): Closure 26 | { 27 | return $this->macro('warning'); 28 | } 29 | 30 | private function macro(string $type): Closure 31 | { 32 | return function (string $message, array $replace = []) use ($type) { 33 | Toaster::toast()->type($type)->message($message, $replace); 34 | 35 | return $this; 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Toaster.php: -------------------------------------------------------------------------------- 1 | message($message, $replace)->error(); 23 | } 24 | 25 | public static function fake(): TestableCollector 26 | { 27 | self::swap($fake = new TestableCollector()); 28 | 29 | return $fake; 30 | } 31 | 32 | public static function info(string $message, array $replace = []): PendingToast 33 | { 34 | return self::toast()->message($message, $replace)->info(); 35 | } 36 | 37 | public static function success(string $message, array $replace = []): PendingToast 38 | { 39 | return self::toast()->message($message, $replace)->success(); 40 | } 41 | 42 | public static function toast(): PendingToast 43 | { 44 | return PendingToast::create(); 45 | } 46 | 47 | public static function warning(string $message, array $replace = []): PendingToast 48 | { 49 | return self::toast()->message($message, $replace)->warning(); 50 | } 51 | 52 | protected static function getFacadeAccessor(): string 53 | { 54 | return ToasterServiceProvider::NAME; 55 | } 56 | 57 | protected static function getMockableClass(): string 58 | { 59 | return Collector::class; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ToasterConfig.php: -------------------------------------------------------------------------------- 1 | alignment); 50 | } 51 | 52 | public function position(): Position 53 | { 54 | return Position::from($this->position); 55 | } 56 | 57 | public function toJavaScript(): array 58 | { 59 | return [ 60 | 'alignment' => $this->alignment, 61 | 'duration' => $this->duration, 62 | 'replace' => $this->wantsReplacement, 63 | 'suppress' => $this->wantsSuppression, 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ToasterHub.php: -------------------------------------------------------------------------------- 1 | view($this->view, [ 23 | 'alignment' => $this->config->alignment(), 24 | 'closeable' => $this->config->wantsCloseableToasts, 25 | 'config' => $this->config->toJavaScript(), 26 | 'position' => $this->config->position(), 27 | 'toasts' => $this->session->pull(SessionRelay::NAME, []), 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ToasterServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom(__DIR__ . '/../resources/views', self::NAME); 23 | 24 | if ($this->app->runningInConsole()) { 25 | $this->registerPublishing(); 26 | } 27 | 28 | $this->callAfterResolving(BladeCompiler::class, $this->aliasToasterHub(...)); 29 | $this->callAfterResolving(Collector::class, $this->relayToLivewire(...)); 30 | $this->callAfterResolving(Router::class, $this->relayToSession(...)); 31 | 32 | Redirector::mixin($macros = new ToastableMacros()); 33 | RedirectResponse::mixin($macros); 34 | } 35 | 36 | public function register(): void 37 | { 38 | $config = $this->configureService(); 39 | 40 | parent::register(); 41 | 42 | $this->app->scoped(Collector::class, QueuingCollector::class); 43 | $this->app->alias(Collector::class, self::NAME); 44 | 45 | if ($config->wantsAccessibility) { 46 | $this->app->extend(Collector::class, static fn (Collector $next) => new AccessibleCollector($next)); 47 | } 48 | 49 | if ($config->wantsTranslation) { 50 | $this->app->extend(Collector::class, fn (Collector $next) => new TranslatingCollector($next, $this->app['translator'])); 51 | } 52 | } 53 | 54 | private function aliasToasterHub(BladeCompiler $blade): void 55 | { 56 | $blade->component(ToasterHub::NAME, ToasterHub::class); 57 | } 58 | 59 | private function configureService(): ToasterConfig 60 | { 61 | $this->mergeConfigFrom(__DIR__ . '/../config/toaster.php', self::NAME); 62 | 63 | $config = ToasterConfig::fromArray($this->app['config'][self::NAME] ?? []); 64 | $this->app->instance(ToasterConfig::class, $config); 65 | $this->app->alias(ToasterConfig::class, self::CONFIG); 66 | 67 | return $config; 68 | } 69 | 70 | private function registerPublishing(): void 71 | { 72 | $this->publishes([ 73 | __DIR__ . '/../config/toaster.php' => $this->app->configPath('toaster.php'), 74 | ], 'toaster-config'); 75 | 76 | $this->publishes([ 77 | __DIR__ . '/../resources/views' => $this->app->resourcePath('views/vendor/toaster'), 78 | ], 'toaster-views'); 79 | } 80 | 81 | private function relayToLivewire(): void 82 | { 83 | $this->app[LivewireManager::class]->listen('dehydrate', new LivewireRelay()); 84 | } 85 | 86 | private function relayToSession(Router $router): void 87 | { 88 | $router->aliasMiddleware(SessionRelay::NAME, SessionRelay::class); 89 | $router->pushMiddlewareToGroup('web', SessionRelay::NAME); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/TranslatingCollector.php: -------------------------------------------------------------------------------- 1 | translator->get($original = $toast->message->value, $toast->message->replace); 18 | 19 | if (is_string($replacement) && $replacement !== $original) { 20 | $toast = ToastBuilder::proto($toast)->message($replacement)->get(); 21 | } 22 | 23 | $this->next->collect($toast); 24 | } 25 | 26 | public function release(): array 27 | { 28 | return $this->next->release(); 29 | } 30 | } 31 | --------------------------------------------------------------------------------