├── .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 |
2 |
3 | # Beautiful toast notifications for Livewire
4 |
5 | [](https://packagist.org/packages/masmerise/livewire-toaster)
6 | [](https://github.com/masmerise/livewire-toaster/actions?query=workflow%3A%22Automated+testing%22+branch%3Amaster)
7 | [](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 |
18 |
19 | ## Compatibility
20 |
21 |
22 | Livewire PHP Laravel
23 |
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 |
358 | Submit
359 |
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 |
11 | is('bottom'))
14 | x-transition:enter-start="translate-y-12 opacity-0"
15 | x-transition:enter-end="translate-y-0 opacity-100"
16 | @elseif($alignment->is('top'))
17 | x-transition:enter-start="-translate-y-12 opacity-0"
18 | x-transition:enter-end="translate-y-0 opacity-100"
19 | @else
20 | x-transition:enter-start="opacity-0 scale-90"
21 | x-transition:enter-end="opacity-100 scale-100"
22 | @endif
23 | x-transition:leave-end="opacity-0 scale-90"
24 | @class(['relative duration-300 transform transition ease-in-out max-w-xs w-full pointer-events-auto', 'text-center' => $position->is('center')])
25 | :class="toast.select({ error: 'text-white', info: 'text-black', success: 'text-white', warning: 'text-white' })"
26 | >
27 |
31 |
32 | @if($closeable)
33 |
34 |
35 |
36 |
37 |
38 | @endif
39 |
40 |
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 |
--------------------------------------------------------------------------------