Skip to content

Commit bba03b1

Browse files
Copilotlimonte
andauthored
feat: Add support for JavaScript callback options (didOpen, willClose, etc.) (#16)
* Initial plan * Add support for JavaScript callback options (didOpen, willClose, etc.) Co-authored-by: limonte <[email protected]> * Improve callback implementation: address code duplication and add security documentation Co-authored-by: limonte <[email protected]> * Add JSON_THROW_ON_ERROR flag for better error handling Co-authored-by: limonte <[email protected]> * Fix test workflow to use local package code instead of published version Co-authored-by: limonte <[email protected]> * Fix JSON encoding to escape HTML characters for security Co-authored-by: limonte <[email protected]> * Use JSON_HEX_TAG flag to escape HTML tags for XSS protection Co-authored-by: limonte <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: limonte <[email protected]>
1 parent 7753207 commit bba03b1

File tree

7 files changed

+255
-7
lines changed

7 files changed

+255
-7
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ jobs:
1919
- name: Install sweetalert2/laravel
2020
run: |
2121
cd sweetalert2-laravel-test
22-
composer require sweetalert2/laravel
22+
composer config repositories.local path ../
23+
composer require sweetalert2/laravel:@dev
2324
2425
- name: Copy Tests
2526
run: |

README.md

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,29 @@ Swal::toastQuestion([
7979
]);
8080
```
8181
82+
### Using JavaScript Callbacks
83+
84+
You can use JavaScript callbacks (like `didOpen`, `willClose`, etc.) by passing them as strings:
85+
86+
```php
87+
// Toast with pause on hover
88+
Swal::fire([
89+
'title' => 'Auto close alert',
90+
'toast' => true,
91+
'position' => 'top-end',
92+
'icon' => 'info',
93+
'showConfirmButton' => false,
94+
'timer' => 3000,
95+
'timerProgressBar' => true,
96+
'didOpen' => '(toast) => {
97+
toast.onmouseenter = Swal.stopTimer;
98+
toast.onmouseleave = Swal.resumeTimer;
99+
}',
100+
]);
101+
```
102+
103+
For more details on using callbacks, see [FAQ #4](#4-what-are-the-limitations).
104+
82105
![SweetAlert2 Laravel](sweetalert2-laravel.png)
83106

84107
## Livewire Components
@@ -162,6 +185,25 @@ $this->swalToastQuestion([
162185
]);
163186
```
164187

188+
### Using JavaScript Callbacks in Livewire
189+
190+
Just like in Laravel controllers, you can use JavaScript callbacks in Livewire components:
191+
192+
```php
193+
$this->swalFire([
194+
'title' => 'Processing...',
195+
'toast' => true,
196+
'position' => 'top-end',
197+
'icon' => 'info',
198+
'timer' => 3000,
199+
'timerProgressBar' => true,
200+
'didOpen' => '(toast) => {
201+
toast.onmouseenter = Swal.stopTimer;
202+
toast.onmouseleave = Swal.resumeTimer;
203+
}',
204+
]);
205+
```
206+
165207
## Inertia.js
166208

167209
You can use `Swal::fire()` or any of the available helper methods in your Inertia.js controllers to show popups after navigation:
@@ -330,5 +372,59 @@ This package uses a smart loading strategy for the SweetAlert2 library:
330372

331373
## 4. What are the limitations?
332374

333-
SweetAlert2 is a JavaScript package and some of its options are JS callbacks. It's not possible to use them in the `Swal::fire()` or `$this->swalFire()` methods.
334-
If you need to use JS callbacks, you have to go to JS and use the SweetAlert2 API directly.
375+
SweetAlert2 is a JavaScript package and some of its options are JS callbacks. While you can pass JavaScript callback functions as strings in the `Swal::fire()` or `$this->swalFire()` methods, keep in mind:
376+
377+
1. **Callbacks must be passed as strings**: Write your JavaScript function as a string. For example:
378+
379+
```php
380+
Swal::fire([
381+
'title' => 'Toast notification',
382+
'toast' => true,
383+
'position' => 'top-end',
384+
'didOpen' => '(toast) => { toast.onmouseenter = Swal.stopTimer; toast.onmouseleave = Swal.resumeTimer; }',
385+
]);
386+
```
387+
388+
2. **Supported callback options**: The following callback options are supported and will be rendered as JavaScript functions:
389+
- `didOpen`
390+
- `didClose`
391+
- `didDestroy`
392+
- `willOpen`
393+
- `willClose`
394+
- `didRender`
395+
- `preDeny`
396+
- `preConfirm`
397+
- `inputValidator`
398+
- `inputOptions`
399+
- `loaderHtml`
400+
401+
3. **Callback limitations**:
402+
- You cannot use PHP variables directly in callback strings (use JavaScript variables or values from the alert instead)
403+
- Complex logic should be kept in JavaScript files and called from the callbacks
404+
- For advanced use cases, consider using the SweetAlert2 API directly in JavaScript
405+
406+
4. **Security considerations**:
407+
- Callback strings are executed as JavaScript in the browser
408+
- **Only pass callback strings from trusted sources (your PHP backend code)**
409+
- **Never pass user input directly as callback strings** to prevent XSS vulnerabilities
410+
- If you need to include dynamic data in callbacks, use regular options or HTML content instead
411+
412+
### Example: Toast with Timer Control
413+
414+
```php
415+
use SweetAlert2\Laravel\Swal;
416+
417+
Swal::fire([
418+
'title' => 'Your session will expire soon',
419+
'toast' => true,
420+
'position' => 'top-end',
421+
'icon' => 'warning',
422+
'showConfirmButton' => false,
423+
'timer' => 3000,
424+
'timerProgressBar' => true,
425+
'didOpen' => '(toast) => {
426+
toast.onmouseenter = Swal.stopTimer;
427+
toast.onmouseleave = Swal.resumeTimer;
428+
}',
429+
]);
430+
```

resources/views/inertia/index.blade.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,24 @@
2525
2626
if (sweetalert2Data && typeof sweetalert2Data === 'object') {
2727
Swal = Swal || await getSweetAlert2();
28-
Swal.fire(sweetalert2Data);
28+
29+
// Handle callbacks in Inertia
30+
const options = {...sweetalert2Data};
31+
const callbackOptions = @json(Swal::CALLBACK_OPTIONS);
32+
33+
callbackOptions.forEach(callback => {
34+
if (typeof options[callback] === 'string') {
35+
try {
36+
// Convert string to function (only for callbacks set by PHP backend, not user input)
37+
options[callback] = new Function('return ' + options[callback])();
38+
} catch (e) {
39+
console.error(`Failed to parse ${callback} callback:`, e);
40+
delete options[callback];
41+
}
42+
}
43+
});
44+
45+
Swal.fire(options);
2946
}
3047
});
3148
</script>

resources/views/laravel/index.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
2323
(async () => {
2424
Swal = await getSweetAlert2();
25-
Swal.fire(@json(session()->pull(Swal::SESSION_KEY)));
25+
{!! Swal::renderFireCall(session()->pull(Swal::SESSION_KEY)) !!};
2626
})();
2727
</script>
2828
@endif

resources/views/livewire/index.blade.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
@if(session()->has(Swal::SESSION_KEY))
2323
(async () => {
2424
Swal = await getSweetAlert2();
25-
Swal.fire(@json(session()->pull(Swal::SESSION_KEY)));
25+
{!! Swal::renderFireCall(session()->pull(Swal::SESSION_KEY)) !!};
2626
})();
2727
@endif
2828
@@ -31,7 +31,24 @@
3131
return;
3232
}
3333
Swal = Swal || await getSweetAlert2();
34-
Swal.fire(event.detail);
34+
35+
// Handle callbacks in Livewire events
36+
const options = {...event.detail};
37+
const callbackOptions = @json(Swal::CALLBACK_OPTIONS);
38+
39+
callbackOptions.forEach(callback => {
40+
if (typeof options[callback] === 'string') {
41+
try {
42+
// Convert string to function (only for callbacks set by PHP backend, not user input)
43+
options[callback] = new Function('return ' + options[callback])();
44+
} catch (e) {
45+
console.error(`Failed to parse ${callback} callback:`, e);
46+
delete options[callback];
47+
}
48+
}
49+
});
50+
51+
Swal.fire(options);
3552
});
3653
</script>
3754

src/Swal.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@
1818
class Swal
1919
{
2020
public const SESSION_KEY = 'sweetalert2-message';
21+
22+
/**
23+
* List of SweetAlert2 options that accept callback functions.
24+
* These will be rendered as JavaScript functions instead of JSON strings.
25+
*
26+
* @var array
27+
*/
28+
public const CALLBACK_OPTIONS = [
29+
'didOpen',
30+
'didClose',
31+
'didDestroy',
32+
'willOpen',
33+
'willClose',
34+
'didRender',
35+
'preDeny',
36+
'preConfirm',
37+
'inputValidator',
38+
'inputOptions',
39+
'loaderHtml',
40+
];
2141
/**
2242
* Displays a SweetAlert2 popup.
2343
*
@@ -199,4 +219,67 @@ public static function toastQuestion(array $options = []): void
199219
{
200220
self::fire([...$options, 'toast' => true, 'icon' => 'question']);
201221
}
222+
223+
/**
224+
* Separates callback options from regular options.
225+
* Callbacks will be stored with a special marker to be rendered as JavaScript functions.
226+
*
227+
* @param array $options The full options array
228+
* @return array Array with 'options' (regular JSON-serializable options) and 'callbacks' (JavaScript callback strings)
229+
*/
230+
public static function separateCallbacks(array $options): array
231+
{
232+
$callbacks = [];
233+
$regularOptions = [];
234+
235+
foreach ($options as $key => $value) {
236+
if (in_array($key, self::CALLBACK_OPTIONS) && is_string($value)) {
237+
$callbacks[$key] = $value;
238+
} else {
239+
$regularOptions[$key] = $value;
240+
}
241+
}
242+
243+
return [
244+
'options' => $regularOptions,
245+
'callbacks' => $callbacks,
246+
];
247+
}
248+
249+
/**
250+
* Renders the Swal.fire() JavaScript call with proper callback handling.
251+
*
252+
* Security: Callback strings are rendered as JavaScript and executed in the browser.
253+
* Only use callback strings from trusted sources (your backend code). Never pass
254+
* user input directly as callback strings to prevent XSS vulnerabilities.
255+
*
256+
* @param array $data The session data containing options
257+
* @return string JavaScript code to call Swal.fire()
258+
*/
259+
public static function renderFireCall(array $data): string
260+
{
261+
$separated = self::separateCallbacks($data);
262+
$options = $separated['options'];
263+
$callbacks = $separated['callbacks'];
264+
265+
if (empty($callbacks)) {
266+
// No callbacks, just render as JSON
267+
return 'Swal.fire(' . json_encode($options, JSON_HEX_TAG | JSON_THROW_ON_ERROR) . ')';
268+
}
269+
270+
// Build JavaScript object with callbacks
271+
$parts = [];
272+
273+
// Add regular options
274+
foreach ($options as $key => $value) {
275+
$parts[] = json_encode($key, JSON_THROW_ON_ERROR) . ': ' . json_encode($value, JSON_HEX_TAG | JSON_THROW_ON_ERROR);
276+
}
277+
278+
// Add callbacks as raw JavaScript
279+
foreach ($callbacks as $key => $callback) {
280+
$parts[] = json_encode($key, JSON_THROW_ON_ERROR) . ': ' . $callback;
281+
}
282+
283+
return 'Swal.fire({' . implode(', ', $parts) . '})';
284+
}
202285
}

tests/SwalTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,37 @@
149149
->assertStatus(200)
150150
->assertSee('Swal.fire({"title":"toast question title","toast":true,"icon":"question"})', escape: false);
151151
});
152+
153+
test('Swal::fire() with didOpen callback', function () {
154+
Swal::fire([
155+
'title' => 'Toast with callbacks',
156+
'toast' => true,
157+
'position' => 'top-end',
158+
'didOpen' => '(toast) => { toast.onmouseenter = Swal.stopTimer; toast.onmouseleave = Swal.resumeTimer; }',
159+
]);
160+
161+
$response = $this->get('/');
162+
163+
$response
164+
->assertStatus(200)
165+
->assertSee('"title": "Toast with callbacks"', escape: false)
166+
->assertSee('"toast": true', escape: false)
167+
->assertSee('"position": "top-end"', escape: false)
168+
->assertSee('"didOpen": (toast) => { toast.onmouseenter = Swal.stopTimer; toast.onmouseleave = Swal.resumeTimer; }', escape: false);
169+
});
170+
171+
test('Swal::fire() with multiple callbacks', function () {
172+
Swal::fire([
173+
'title' => 'Multiple callbacks',
174+
'icon' => 'info',
175+
'didOpen' => '() => { console.log("opened"); }',
176+
'willClose' => '() => { console.log("closing"); }',
177+
]);
178+
179+
$response = $this->get('/');
180+
181+
$response
182+
->assertStatus(200)
183+
->assertSee('"didOpen": () => { console.log("opened"); }', escape: false)
184+
->assertSee('"willClose": () => { console.log("closing"); }', escape: false);
185+
});

0 commit comments

Comments
 (0)