From 0891b6209d93bf4b2182a3c1bedb8a001f606f3c Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Fri, 28 Aug 2026 14:48:41 +0100 Subject: [PATCH 1/5] feat: Enhance ToastKit with new animations, direction, progress, and loading features - Added new animation types: SNAP, POP, REVEAL, and BOUNCE to ToastKitAnimation. - Introduced ToastKitDirection enum for customizable toast direction (AUTO, LEFT, RIGHT, TOP, BOTTOM). - Implemented progress tracking and loading state in ToastKitConfiguration. - Updated Android and iOS implementations to support new animations and properties. - Enhanced ToastKitHost to display progress indicators and handle loading states. - Added validation for new properties in JavaScript and PHP implementations. - Created a ToastPresetRegistry for managing toast presets and their configurations. - Added tests for new features, ensuring proper functionality and validation. --- CHANGELOG.md | 6 ++ README.md | 99 ++++++++++++++++++- .../bridge/ToastKitBridgeNormalizer.kt | 14 ++- .../toastkit/manager/ToastKitManager.kt | 6 +- .../plugins/toastkit/model/ToastKitModels.kt | 6 +- .../toastkit/presentation/ToastKitHost.kt | 68 +++++++++++-- .../Bridge/ToastKitBridgeNormalizer.swift | 12 +++ .../ios/Sources/Manager/ToastKitManager.swift | 5 +- .../ios/Sources/Model/ToastKitModels.swift | 6 +- .../Sources/Presentation/ToastKitHost.swift | 58 ++++++++++- resources/js/PendingToast.js | 2 + resources/js/PendingToastBase.js | 10 ++ resources/js/tests/toastkit.test.js | 25 ++++- resources/js/validation.js | 3 +- src/Concerns/ConfiguresToast.php | 41 +++++--- src/Enums/ToastAnimation.php | 6 +- src/Enums/ToastDirection.php | 12 +++ src/Events/ToastDismissed.php | 4 +- .../InvalidToastConfigurationException.php | 5 + src/Exceptions/PresetNotFoundException.php | 5 + src/Exceptions/ToastKitException.php | 7 ++ src/Facades/Toast.php | 2 + src/PendingToast.php | 10 +- src/PendingToastUpdate.php | 4 +- src/ToastKit.php | 19 +++- src/ToastKitServiceProvider.php | 3 +- src/ToastPresetRegistry.php | 30 ++++++ tests/Unit/ConfigurationTest.php | 23 ++++- tests/Unit/PresetTest.php | 57 +++++++++++ tests/Unit/UpdateTest.php | 8 ++ tests/Unit/ValidationTest.php | 2 +- 31 files changed, 512 insertions(+), 46 deletions(-) create mode 100644 src/Enums/ToastDirection.php create mode 100644 src/Exceptions/InvalidToastConfigurationException.php create mode 100644 src/Exceptions/PresetNotFoundException.php create mode 100644 src/Exceptions/ToastKitException.php create mode 100644 src/ToastPresetRegistry.php create mode 100644 tests/Unit/PresetTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8dab9..31d5e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Native `snap`, `pop`, `reveal`, and `bounce` animations with independent `auto`, `left`, `right`, `top`, and `bottom` direction control. +- Determinate `progress()` and indeterminate `loading()` APIs with smooth sparse updates on Android and iOS. +- Laravel-container-backed reusable toast presets through `Toast::definePreset()` and `Toast::preset()`. +- A catchable ToastKit exception hierarchy with configuration and missing-preset exceptions. +- JavaScript parity for animations, direction, progress, and loading. + - Initial public API: `Toast` facade, `PendingToast` and `PendingToastUpdate` builders. - Five variants (`neutral`, `success`, `error`, `warning`, `info`) with native defaults. - Content options: title, message, icons with iOS/Android overrides, and action buttons. diff --git a/README.md b/README.md index 7e25233..f3a191b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - **Five variants** — `success`, `error`, `warning`, `info`, and `neutral`. - **Rich content** — title, message, and icons with per-platform overrides. -- **Full customization** — position, duration, animation, swipe-to-dismiss, close control, colors, corner radius, padding, and shadow. +- **Full customization** — position, animation direction, progress, loading, swipe-to-dismiss, close control, colors, corner radius, padding, and shadow. - **Action buttons** — a native action button with its own ID and pressed event. - **Queue strategy** — FIFO, one toast at a time. - **Stack strategy** — up to `maxVisible` toasts on screen with FIFO overflow. @@ -100,7 +100,7 @@ Toast::make('Download complete') ->show(); ``` -Supported options include `title()`, `icon()`, `position()`, `duration()`, `persistent()`, `animation()`, `swipeToDismiss()`, `dismissible()`, `action()`, and the styling methods `background()`, `foreground()`, `iconColor()`, `actionColor()`, `cornerRadius()`, `padding()`, and `shadow()`. See the [API Reference](#api-reference) for the full list. +Supported options include `title()`, `icon()`, `position()`, `duration()`, `persistent()`, `animation()`, `direction()`, `progress()`, `loading()`, `swipeToDismiss()`, `dismissible()`, `action()`, and the styling methods `background()`, `foreground()`, `iconColor()`, `actionColor()`, `cornerRadius()`, `padding()`, and `shadow()`. See the [API Reference](#api-reference) for the full list. ## Icons @@ -237,6 +237,89 @@ Toast::update($id) ->show(); ``` +## Progress & Loading + +Use determinate progress for work with a known percentage. Values are clamped to `0`–`100`, and updates animate without replacing the toast: + +```php +$id = Toast::info('Uploading...')->persistent()->progress(0)->show(); + +Toast::update($id)->progress(42.5)->show(); +Toast::update($id)->progress(100)->message('Upload complete')->show(); +``` + +Use `loading()` when progress is unknown, and `loading(false)` to stop it: + +```php +$id = Toast::info('Connecting...')->persistent()->loading()->show(); +Toast::update($id)->loading(false)->message('Connected')->duration(1500)->show(); +``` + +If a payload contains both `progress` and `loading: true`, determinate progress wins. This makes partial updates deterministic while preserving both values in the transport contract. + +## Animations & Direction + +ToastKit supports `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, and `bounce`. Direction is independent and accepts `auto`, `left`, `right`, `top`, or `bottom`: + +```php +Toast::success('Published') + ->animation('bounce') + ->direction('right') + ->show(); +``` + +`auto` is the default and derives the motion from the toast position. Reduced-motion platform settings use a short fade instead of spatial or spring motion. + +## Presets + +Define reusable presets during application boot, such as in a service provider: + +```php +use Victorycodedev\ToastKit\PendingToast; +use Victorycodedev\ToastKit\Facades\Toast; + +Toast::definePreset('syncing', fn (PendingToast $toast) => $toast + ->message('Syncing...') + ->info() + ->persistent() + ->loading()); +``` + +Each call creates a fresh builder, and options chained afterward override the preset: + +```php +Toast::preset('syncing')->message('Syncing contacts...')->show(); +``` + +Defining the same name again replaces the previous definition. Missing presets throw `PresetNotFoundException`. All intentional public configuration failures extend `ToastKitException` (and remain compatible with `InvalidArgumentException`). Presets are PHP-only because they are registered in the Laravel application lifecycle. + +Small applications can keep these definitions directly in `AppServiceProvider::boot()`. For larger applications, a convenient optional organization is an `App\Toasts\ToastPresets` class with a static `register()` method, called from `AppServiceProvider::boot()`. ToastKit does not generate or require this class; it simply keeps a longer preset catalog out of the provider. Precedence is: ToastKit defaults → preset → per-toast configuration → sparse update configuration. + +```php +namespace App\Toasts; + +use Victorycodedev\ToastKit\Facades\Toast; + +final class ToastPresets +{ + public static function register(): void + { + Toast::definePreset('payment-success', fn ($toast) => $toast + ->success()->icon('check_circle')->animation('reveal') + ->position('top')->duration(3000)); + + Toast::definePreset('payment-failed', fn ($toast) => $toast + ->error()->icon('error')->animation('snap')->direction('left') + ->position('top')->duration(5000)); + + Toast::definePreset('uploading', fn ($toast) => $toast + ->info()->loading()->persistent()); + } +} +``` + +Then call `ToastPresets::register()` from your application's `AppServiceProvider::boot()`. + ## Dismissing Toasts ```php @@ -608,6 +691,8 @@ Native::test(ProfileScreen::class) | `Toast::warning(string $message)` | A warning-variant toast. | | `Toast::info(string $message)` | An info-variant toast. | | `Toast::neutral(string $message)` | A neutral-variant toast. | +| `Toast::definePreset(string $name, Closure $preset)` | Define or replace a reusable PHP preset. | +| `Toast::preset(string $name)` | Create a fresh builder from a preset. | | `Toast::update(string $id)` | Start building an update for an existing toast. | | `Toast::dismiss(string $id)` | Dismiss a toast by ID. | | `Toast::dismissAll()` | Dismiss all active and queued toasts. | @@ -629,7 +714,10 @@ Native::test(ProfileScreen::class) | `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. | | `duration(int $milliseconds)` | Set the visible duration (makes the toast timed). | | `persistent(bool $persistent = true)` | Make the toast persistent (no timeout). | -| `animation(ToastAnimation\|string $animation)` | `fade`, `slide`, `scale`, or `spring`. | +| `animation(ToastAnimation\|string $animation)` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, or `bounce`. | +| `direction(ToastDirection\|string $direction)` | `auto`, `left`, `right`, `top`, or `bottom`. | +| `progress(int\|float $progress)` | Set determinate progress, clamped to `0`–`100`. | +| `loading(bool $loading = true)` | Enable or disable indeterminate progress. | | `swipeToDismiss(bool $enabled = true)` | Enable or disable swipe-to-dismiss. | | `dismissible(bool $enabled = true)` | Show a visible close control. | | `action(string $label, string $id)` | Add an action button with a label and ID. | @@ -656,7 +744,8 @@ Native::test(ProfileScreen::class) | --- | --- | | `ToastVariant` | `success`, `error`, `warning`, `info`, `neutral` | | `ToastPosition` | `top`, `center`, `bottom` | -| `ToastAnimation` | `fade`, `slide`, `scale`, `spring` | +| `ToastAnimation` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, `bounce` | +| `ToastDirection` | `auto`, `left`, `right`, `top`, `bottom` | | `ToastStrategy` | `queue`, `stack` | | `ToastTextSize` | `xs`, `sm`, `base`, `lg`, `xl` | | `ToastTextWeight` | `normal`, `medium`, `semibold`, `bold` | @@ -672,6 +761,8 @@ Native::test(ProfileScreen::class) | `duration` | `3000` ms | | `persistent` | `false` | | `animation` | `scale` | +| `direction` | `auto` | +| `loading` | `false` | | `swipe_to_dismiss` | `true` | | `dismissible` | `false` | | `strategy` | `queue` | diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt index 89178ed..756770b 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -33,6 +33,9 @@ internal object ToastKitBridgeNormalizer { if (changes.containsKey("icon")) next = next.copy(icon = optionalValue(changes["icon"])?.let(::icon)) changes["position"]?.let { next = next.copy(position = position(it)) } changes["animation"]?.let { next = next.copy(animation = animation(it)) } + changes["direction"]?.let { next = next.copy(direction = direction(it)) } + if (changes.containsKey("progress")) next = next.copy(progress = optionalValue(changes["progress"])?.let(::progress)) + changes["loading"]?.let { next = next.copy(loading = boolean(it, "loading")) } changes["swipe_to_dismiss"]?.let { next = next.copy(swipeToDismiss = boolean(it, "swipe_to_dismiss")) } changes["dismissible"]?.let { next = next.copy(dismissible = boolean(it, "dismissible")) } if (changes.containsKey("action")) next = next.copy(action = optionalValue(changes["action"])?.let(::action)) @@ -63,6 +66,9 @@ internal object ToastKitBridgeNormalizer { position = position(values["position"] ?: "bottom"), durationMs = if (persistent) null else positiveLong(values["duration"] ?: 3000, "duration"), animation = animation(values["animation"] ?: "scale"), + direction = direction(values["direction"] ?: "auto"), + progress = optionalValue(values["progress"])?.let(::progress), + loading = values["loading"]?.let { boolean(it, "loading") } ?: false, swipeToDismiss = values["swipe_to_dismiss"]?.let { boolean(it, "swipe_to_dismiss") } ?: true, dismissible = values["dismissible"]?.let { boolean(it, "dismissible") } ?: false, action = optionalValue(values["action"])?.let(::action), @@ -160,7 +166,8 @@ internal object ToastKitBridgeNormalizer { private fun variant(value: Any): String = enumString(value, "variant", setOf("neutral", "success", "error", "warning", "info")) private fun position(value: Any) = ToastKitPosition.valueOf(enumString(value, "position", setOf("top", "center", "bottom")).uppercase()) - private fun animation(value: Any) = ToastKitAnimation.valueOf(enumString(value, "animation", setOf("fade", "slide", "scale", "spring")).uppercase()) + private fun animation(value: Any) = ToastKitAnimation.valueOf(enumString(value, "animation", setOf("fade", "slide", "scale", "spring", "snap", "pop", "reveal", "bounce")).uppercase()) + private fun direction(value: Any) = ToastKitDirection.valueOf(enumString(value, "direction", setOf("auto", "left", "right", "top", "bottom")).uppercase()) private fun strategy(value: Any) = ToastKitStrategy.valueOf(enumString(value, "strategy", setOf("queue", "stack")).uppercase()) private fun textSize(value: Any) = ToastKitTextSize.valueOf(enumString(value, "text size", setOf("xs", "sm", "base", "lg", "xl")).uppercase()) private fun textWeight(value: Any) = ToastKitTextWeight.valueOf(enumString(value, "text weight", setOf("normal", "medium", "semibold", "bold")).uppercase()) @@ -194,6 +201,11 @@ internal object ToastKitBridgeNormalizer { requireInput(result >= 0, "$name must not be negative") return result } + private fun progress(value: Any): Float { + val result = (value as? Number)?.toFloat() ?: throw ToastKitInputException("progress must be numeric") + requireInput(result.isFinite(), "progress must be finite") + return result.coerceIn(0f, 100f) + } private fun stringMap(value: Any?): Map? = when (value) { is JSONObject -> value.keys().asSequence().associate { key -> key to value.get(key) } is Map<*, *> -> value.entries.associate { (key, v) -> key.toString() to (v ?: JSONObject.NULL) } diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt index d1969a9..ba8fda5 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt @@ -84,7 +84,11 @@ internal object ToastKitManager { waiting.remove(id) visible.removeAll { it.id == id } exiting.add(id) - val exitDuration = if (state.configuration.animation == ToastKitAnimation.SPRING) 450L else 260L + val exitDuration = when (state.configuration.animation) { + ToastKitAnimation.SPRING, ToastKitAnimation.BOUNCE -> 450L + ToastKitAnimation.REVEAL -> 280L + else -> 260L + } main.postDelayed({ rendered.removeAll { it.id == id } exiting.remove(id) diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt index bb1b354..96de19b 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -3,7 +3,8 @@ package com.victorycodedev.plugins.toastkit.model import androidx.compose.ui.graphics.Color internal enum class ToastKitPosition { TOP, CENTER, BOTTOM } -internal enum class ToastKitAnimation { FADE, SLIDE, SCALE, SPRING } +internal enum class ToastKitAnimation { FADE, SLIDE, SCALE, SPRING, SNAP, POP, REVEAL, BOUNCE } +internal enum class ToastKitDirection { AUTO, LEFT, RIGHT, TOP, BOTTOM } internal enum class ToastKitStrategy { QUEUE, STACK } internal enum class ToastKitTextSize { XS, SM, BASE, LG, XL } internal enum class ToastKitTextWeight { NORMAL, MEDIUM, SEMIBOLD, BOLD } @@ -37,6 +38,9 @@ internal data class ToastKitConfiguration( val position: ToastKitPosition, val durationMs: Long?, val animation: ToastKitAnimation, + val direction: ToastKitDirection, + val progress: Float?, + val loading: Boolean, val swipeToDismiss: Boolean, val dismissible: Boolean, val action: ToastKitAction?, diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt index e0a8059..ac99897 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -66,6 +68,11 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { val accessibility = LocalContext.current.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager val animationsDisabled = Settings.Global.getFloat(LocalContext.current.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f val reduceMotion = animationsDisabled || (accessibility?.isEnabled == true && accessibility.isTouchExplorationEnabled) + val displayedProgress by animateFloatAsState( + targetValue = (toast.progress ?: 0f) / 100f, + animationSpec = if (reduceMotion) tween(100) else tween(220), + label = "toast-progress", + ) val gesture = if (!toast.swipeToDismiss) Modifier else Modifier.pointerInput(toast.id) { detectDragGestures( onDragStart = { velocityTracker.resetTracking() }, @@ -89,30 +96,39 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { } val enter = if (reduceMotion) fadeIn(tween(120)) else when (toast.animation) { ToastKitAnimation.FADE -> fadeIn(tween(180)) - ToastKitAnimation.SLIDE -> slideInVertically(tween(220)) { if (toast.position == ToastKitPosition.BOTTOM) it else -it } + fadeIn() + ToastKitAnimation.SLIDE -> directionalEnter(toast, tween(220)) + fadeIn() ToastKitAnimation.SCALE -> scaleIn(tween(180), initialScale = .9f) + fadeIn() ToastKitAnimation.SPRING -> scaleIn(spring(), initialScale = .86f) + fadeIn() + ToastKitAnimation.SNAP -> directionalEnter(toast, spring(dampingRatio = .68f, stiffness = 950f)) + fadeIn(tween(90)) + ToastKitAnimation.POP -> scaleIn(spring(dampingRatio = .55f, stiffness = 650f), initialScale = .72f) + fadeIn(tween(100)) + ToastKitAnimation.REVEAL -> expandHorizontally(tween(240), expandFrom = revealAlignment(toast)) + fadeIn(tween(160)) + ToastKitAnimation.BOUNCE -> directionalEnter(toast, spring(dampingRatio = .45f, stiffness = 360f)) + fadeIn(tween(120)) } val exit = if (reduceMotion) fadeOut(tween(100)) else when (toast.animation) { ToastKitAnimation.FADE -> fadeOut(tween(160)) - ToastKitAnimation.SLIDE -> slideOutVertically(tween(220)) { if (toast.position == ToastKitPosition.BOTTOM) it else -it } + fadeOut() + ToastKitAnimation.SLIDE -> directionalExit(toast, tween(220)) + fadeOut() ToastKitAnimation.SCALE -> scaleOut(tween(160), targetScale = .9f) + fadeOut() ToastKitAnimation.SPRING -> scaleOut(spring(), targetScale = .86f) + fadeOut(tween(220)) + ToastKitAnimation.SNAP -> directionalExit(toast, tween(110)) + fadeOut(tween(90)) + ToastKitAnimation.POP -> scaleOut(tween(150), targetScale = .72f) + fadeOut(tween(120)) + ToastKitAnimation.REVEAL -> shrinkHorizontally(tween(210), shrinkTowards = revealAlignment(toast)) + fadeOut(tween(150)) + ToastKitAnimation.BOUNCE -> directionalExit(toast, tween(220)) + fadeOut(tween(160)) } val visibility = remember(toast.id) { MutableTransitionState(false) } val shouldBeVisible = toast.id !in ToastKitManager.exiting LaunchedEffect(shouldBeVisible) { visibility.targetState = shouldBeVisible } AnimatedVisibility(visibleState = visibility, enter = enter, exit = exit) { - Row( + Column( modifier = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp).fillMaxWidth() .graphicsLayer { translationX = displayedX; translationY = displayedY; alpha = (1f - (abs(displayedX) + abs(displayedY)) / 700f).coerceIn(.35f, 1f) } .then(if (toast.style.shadow) Modifier.shadow(8.dp, RoundedCornerShape(toast.style.cornerRadius.dp)) else Modifier) .clip(RoundedCornerShape(toast.style.cornerRadius.dp)).background(toast.style.background) .padding(toast.style.padding.dp).then(gesture), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - toast.icon?.let { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (toast.progress == null && toast.loading) { + CircularProgressIndicator(Modifier.size(22.dp), color = toast.style.iconColor, strokeWidth = 2.5.dp) + } else toast.icon?.let { MaterialIcon(it.android ?: it.name ?: "circle", "Toast icon", size = 24.dp, tint = toast.style.iconColor) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -148,10 +164,50 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { MaterialIcon("close", "Dismiss", size = 20.dp, tint = toast.style.foreground) } } + } + toast.progress?.let { + Spacer(Modifier.height(10.dp)) + LinearProgressIndicator( + progress = { displayedProgress }, + modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)), + color = toast.style.actionColor, + trackColor = toast.style.foreground.copy(alpha = .22f), + ) + } } } } +private fun resolvedDirection(toast: ToastKitConfiguration): ToastKitDirection = when (toast.direction) { + ToastKitDirection.AUTO -> when (toast.position) { + ToastKitPosition.TOP -> ToastKitDirection.TOP + ToastKitPosition.BOTTOM -> ToastKitDirection.BOTTOM + ToastKitPosition.CENTER -> ToastKitDirection.TOP + } + else -> toast.direction +} + +private fun directionalEnter(toast: ToastKitConfiguration, spec: androidx.compose.animation.core.FiniteAnimationSpec): EnterTransition = + when (resolvedDirection(toast)) { + ToastKitDirection.LEFT -> slideInHorizontally(spec) { -it } + ToastKitDirection.RIGHT -> slideInHorizontally(spec) { it } + ToastKitDirection.TOP -> slideInVertically(spec) { -it } + else -> slideInVertically(spec) { it } + } + +private fun directionalExit(toast: ToastKitConfiguration, spec: androidx.compose.animation.core.FiniteAnimationSpec): ExitTransition = + when (resolvedDirection(toast)) { + ToastKitDirection.LEFT -> slideOutHorizontally(spec) { -it } + ToastKitDirection.RIGHT -> slideOutHorizontally(spec) { it } + ToastKitDirection.TOP -> slideOutVertically(spec) { -it } + else -> slideOutVertically(spec) { it } + } + +private fun revealAlignment(toast: ToastKitConfiguration): Alignment.Horizontal = when (resolvedDirection(toast)) { + ToastKitDirection.RIGHT -> Alignment.End + else -> Alignment.Start +} + private fun toastTextSize(size: ToastKitTextSize) = when (size) { ToastKitTextSize.XS -> 11.sp ToastKitTextSize.SM -> 13.sp diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index 70bd43f..1e797d5 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -35,6 +35,9 @@ enum ToastKitBridgeNormalizer { if let value = changes["icon"] { next.icon = try icon(value) } if let value = changes["position"] { next.position = try enumValue(value, "position") } if let value = changes["animation"] { next.animation = try enumValue(value, "animation") } + if let value = changes["direction"] { next.direction = try enumValue(value, "direction") } + if changes.keys.contains("progress") { next.progress = try changes["progress"].map(progress) } + if let value = changes["loading"] { next.loading = try boolean(value, "loading") } if let value = changes["swipe_to_dismiss"] { next.swipeToDismiss = try boolean(value, "swipe_to_dismiss") } if let value = changes["dismissible"] { next.dismissible = try boolean(value, "dismissible") } if changes.keys.contains("action") { next.action = try changes["action"].map(action) } @@ -63,6 +66,9 @@ enum ToastKitBridgeNormalizer { position: try enumValue(values["position"] ?? "bottom", "position"), durationMilliseconds: persistent ? nil : try positiveInt(values["duration"] ?? 3000, "duration"), animation: try enumValue(values["animation"] ?? "scale", "animation"), + direction: try enumValue(values["direction"] ?? "auto", "direction"), + progress: try values["progress"].map(progress), + loading: try values["loading"].map { try boolean($0, "loading") } ?? false, swipeToDismiss: try values["swipe_to_dismiss"].map { try boolean($0, "swipe_to_dismiss") } ?? true, dismissible: try values["dismissible"].map { try boolean($0, "dismissible") } ?? false, action: try values["action"].map(action), @@ -189,4 +195,10 @@ enum ToastKitBridgeNormalizer { private static func nonNegative(_ value: Any, _ name: String) throws -> CGFloat { guard let result = (value as? NSNumber)?.doubleValue, result >= 0 else { throw ToastKitInputError.invalid("\(name) must not be negative") }; return CGFloat(result) } + private static func progress(_ value: Any) throws -> Double { + guard let result = (value as? NSNumber)?.doubleValue, result.isFinite else { + throw ToastKitInputError.invalid("progress must be a finite number") + } + return min(100, max(0, result)) + } } diff --git a/resources/ios/Sources/Manager/ToastKitManager.swift b/resources/ios/Sources/Manager/ToastKitManager.swift index 2d9aab4..b668105 100644 --- a/resources/ios/Sources/Manager/ToastKitManager.swift +++ b/resources/ios/Sources/Manager/ToastKitManager.swift @@ -104,8 +104,11 @@ final class ToastKitManager: ObservableObject { private func animation(for toast: ToastKitConfiguration) -> Animation { switch toast.animation { - case .fade, .slide, .scale: return .easeInOut(duration: 0.2) + case .fade, .slide, .scale, .reveal: return .easeInOut(duration: 0.2) + case .snap: return .spring(response: 0.22, dampingFraction: 0.62) case .spring: return .spring(response: 0.35, dampingFraction: 0.78) + case .pop: return .spring(response: 0.28, dampingFraction: 0.58) + case .bounce: return .spring(response: 0.48, dampingFraction: 0.55) } } } diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index 8a74a62..70e0b15 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -1,7 +1,8 @@ import SwiftUI enum ToastKitPosition: String { case top, center, bottom } -enum ToastKitAnimationKind: String { case fade, slide, scale, spring } +enum ToastKitAnimationKind: String { case fade, slide, scale, spring, snap, pop, reveal, bounce } +enum ToastKitDirection: String { case auto, left, right, top, bottom } enum ToastKitStrategy: String { case queue, stack } enum ToastKitTextSize: String { case xs, sm, base, lg, xl } enum ToastKitTextWeight: String { case normal, medium, semibold, bold } @@ -44,6 +45,9 @@ struct ToastKitConfiguration: Identifiable { var position: ToastKitPosition var durationMilliseconds: Int? var animation: ToastKitAnimationKind + var direction: ToastKitDirection + var progress: Double? + var loading: Bool var swipeToDismiss: Bool var dismissible: Bool var action: ToastKitActionConfiguration? diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index fb61d5d..c755a0f 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -38,13 +38,56 @@ struct ToastKitHost: View { if reduceMotion { return .opacity } switch toast.animation { case .fade: return .opacity - case .slide: return .move(edge: toast.position == .top ? .top : .bottom).combined(with: .opacity) + case .slide: return .move(edge: edge(for: toast)).combined(with: .opacity) case .scale: return .scale(scale: 0.9).combined(with: .opacity) case .spring: return .scale(scale: 0.86).combined(with: .opacity) + case .snap: return .move(edge: edge(for: toast)).combined(with: .opacity) + case .pop: return .scale(scale: 0.72).combined(with: .opacity) + case .reveal: + return .modifier( + active: ToastKitRevealModifier(amount: 0.01, anchor: anchor(for: toast)), + identity: ToastKitRevealModifier(amount: 1, anchor: anchor(for: toast)) + ).combined(with: .opacity) + case .bounce: return .move(edge: edge(for: toast)).combined(with: .opacity) + } + } + + private func edge(for toast: ToastKitConfiguration) -> Edge { + switch resolvedDirection(for: toast) { + case .left: return .leading + case .right: return .trailing + case .top: return .top + case .bottom, .auto: return .bottom + } + } + + private func anchor(for toast: ToastKitConfiguration) -> UnitPoint { + switch resolvedDirection(for: toast) { + case .right: return .trailing + case .top: return .top + case .bottom: return .bottom + default: return .leading + } + } + + private func resolvedDirection(for toast: ToastKitConfiguration) -> ToastKitDirection { + guard toast.direction == .auto else { return toast.direction } + switch toast.position { + case .top: return .top + case .bottom, .center: return .bottom } } } +private struct ToastKitRevealModifier: ViewModifier { + let amount: CGFloat + let anchor: UnitPoint + + func body(content: Content) -> some View { + content.scaleEffect(x: amount, y: 1, anchor: anchor).clipped() + } +} + private struct ToastKitView: View { let toast: ToastKitConfiguration let reduceMotion: Bool @@ -52,7 +95,9 @@ private struct ToastKitView: View { var body: some View { HStack(spacing: 12) { - if let icon = toast.icon { + if toast.progress == nil && toast.loading { + ProgressView().tint(toast.style.iconColor).controlSize(.small) + } else if let icon = toast.icon { Image(systemName: icon.ios ?? getIconForName(icon.name ?? "circle")) .font(.system(size: 21, weight: .semibold)).foregroundStyle(toast.style.iconColor) .accessibilityHidden(true) @@ -95,6 +140,15 @@ private struct ToastKitView: View { } } .toastKitConditional(toast.swipeToDismiss) { $0.gesture(dragGesture) } + .overlay(alignment: .bottom) { + if let progress = toast.progress { + ProgressView(value: progress, total: 100) + .tint(toast.style.actionColor) + .padding(.horizontal, toast.style.padding) + .padding(.bottom, 4) + .animation(reduceMotion ? .linear(duration: 0.1) : .easeInOut(duration: 0.2), value: progress) + } + } .animation(reduceMotion ? .linear(duration: 0.1) : .spring(response: 0.3, dampingFraction: 0.78), value: drag) } diff --git a/resources/js/PendingToast.js b/resources/js/PendingToast.js index 7379c4b..c71fe5a 100644 --- a/resources/js/PendingToast.js +++ b/resources/js/PendingToast.js @@ -61,6 +61,8 @@ export class PendingToast extends PendingToastBase { duration: 3000, persistent: false, animation: "scale", + direction: "auto", + loading: false, swipe_to_dismiss: true, dismissible: false, style: { corner_radius: 16, padding: 16, shadow: true }, diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index 7efc57b..22a083c 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -67,6 +67,16 @@ export class PendingToastBase { animation(value) { return this.set("animation", oneOf(value, "animation")); } + direction(value) { + return this.set("direction", oneOf(value, "direction")); + } + progress(value) { + if (!Number.isFinite(value)) throw new TypeError("progress must be a finite number"); + return this.set("progress", Math.max(0, Math.min(100, value))); + } + loading(enabled = true) { + return this.set("loading", Boolean(enabled)); + } swipeToDismiss(enabled = true) { return this.set("swipe_to_dismiss", Boolean(enabled)); } diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index 627aac2..aaa52c2 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -81,6 +81,27 @@ test("position, duration, persistent and animation are set", () => { assert.equal(Toast.make("x").persistent().payload().duration, null); }); +test("new animations, direction, progress and loading are set", () => { + for (const animation of ["snap", "pop", "reveal", "bounce"]) + assert.equal(Toast.make("x").animation(animation).payload().animation, animation); + assert.equal(Toast.make("x").direction("left").payload().direction, "left"); + assert.equal(Toast.make("x").progress(-5).payload().progress, 0); + assert.equal(Toast.make("x").progress(42.5).payload().progress, 42.5); + assert.equal(Toast.make("x").progress(105).payload().progress, 100); + assert.equal(Toast.make("x").loading().payload().loading, true); + assert.equal(Toast.make("x").loading(false).payload().loading, false); +}); + +test("progress and loading updates remain sparse", async () => { + const calls = []; + fakeBridge(calls); + await Toast.update("upload").progress(40).show(); + await Toast.update("upload").loading(false).show(); + assert.deepEqual(calls[0].params.changes, { progress: 40 }); + assert.deepEqual(calls[1].params.changes, { loading: false }); + resetFetch(); +}); + test("swipe, dismissible and action are set", () => { const payload = Toast.make("x") .swipeToDismiss(false) @@ -195,7 +216,9 @@ test("validation rejects bad input", () => { assert.throws(() => Toast.make(""), /message/); assert.throws(() => Toast.make("x").background("red"), /Colors/); assert.throws(() => Toast.make("x").position("left"), /position/); - assert.throws(() => Toast.make("x").animation("bounce"), /animation/); + assert.throws(() => Toast.make("x").animation("spin"), /animation/); + assert.throws(() => Toast.make("x").direction("diagonal"), /direction/); + assert.throws(() => Toast.make("x").progress(Infinity), /progress/); assert.throws(() => Toast.make("x").variant("danger"), /variant/); assert.throws(() => Toast.make("x").strategy("grid"), /strategy/); assert.throws(() => Toast.make("x").maxVisible(0), /maxVisible/); diff --git a/resources/js/validation.js b/resources/js/validation.js index 515cb19..b17e930 100644 --- a/resources/js/validation.js +++ b/resources/js/validation.js @@ -1,7 +1,8 @@ export const allowed = { variant: ["neutral", "success", "error", "warning", "info"], position: ["top", "center", "bottom"], - animation: ["fade", "slide", "scale", "spring"], + animation: ["fade", "slide", "scale", "spring", "snap", "pop", "reveal", "bounce"], + direction: ["auto", "left", "right", "top", "bottom"], strategy: ["queue", "stack"], size: ["xs", "sm", "base", "lg", "xl"], weight: ["normal", "medium", "semibold", "bold"], diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 8a09d6d..e79a768 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -3,14 +3,15 @@ namespace Victorycodedev\ToastKit\Concerns; use BackedEnum; -use InvalidArgumentException; use Victorycodedev\ToastKit\Enums\ToastAnimation; +use Victorycodedev\ToastKit\Enums\ToastDirection; use Victorycodedev\ToastKit\Enums\ToastPosition; use Victorycodedev\ToastKit\Enums\ToastStrategy; use Victorycodedev\ToastKit\Enums\ToastTextAlign; use Victorycodedev\ToastKit\Enums\ToastTextSize; use Victorycodedev\ToastKit\Enums\ToastTextWeight; use Victorycodedev\ToastKit\Enums\ToastVariant; +use Victorycodedev\ToastKit\Exceptions\InvalidToastConfigurationException; trait ConfiguresToast { @@ -20,7 +21,7 @@ abstract protected function putTypography(string $group, array $values): static; public function message(string $message): static { - if (trim($message) === '') throw new InvalidArgumentException('A toast message must not be empty.'); + if (trim($message) === '') throw new InvalidToastConfigurationException('A toast message must not be empty.'); return $this->put('message', $message); } @@ -82,7 +83,7 @@ public function icon(?string $name = null, ?string $ios = null, ?string $android 'ios' => $this->nullableNonEmpty($ios, 'iOS icon name'), 'android' => $this->nullableNonEmpty($android, 'Android icon name'), ], static fn($value) => $value !== null); - if ($icon === []) throw new InvalidArgumentException('An icon name or platform override is required.'); + if ($icon === []) throw new InvalidToastConfigurationException('An icon name or platform override is required.'); return $this->put('icon', $icon); } @@ -93,7 +94,7 @@ public function position(ToastPosition|string $position): static public function duration(int $milliseconds): static { - if ($milliseconds <= 0) throw new InvalidArgumentException('Toast duration must be greater than zero milliseconds.'); + if ($milliseconds <= 0) throw new InvalidToastConfigurationException('Toast duration must be greater than zero milliseconds.'); $this->put('persistent', false); return $this->put('duration', $milliseconds); } @@ -109,6 +110,22 @@ public function animation(ToastAnimation|string $animation): static return $this->put('animation', $this->enumValue(ToastAnimation::class, $animation, 'animation')); } + public function direction(ToastDirection|string $direction): static + { + return $this->put('direction', $this->enumValue(ToastDirection::class, $direction, 'direction')); + } + + public function progress(int|float $progress): static + { + if (! is_finite((float) $progress)) throw new InvalidToastConfigurationException('Toast progress must be a finite number.'); + return $this->put('progress', max(0, min(100, $progress))); + } + + public function loading(bool $loading = true): static + { + return $this->put('loading', $loading); + } + public function swipeToDismiss(bool $enabled = true): static { return $this->put('swipe_to_dismiss', $enabled); @@ -120,7 +137,7 @@ public function dismissible(bool $enabled = true): static public function action(string $label, string $id): static { - if (trim($label) === '' || trim($id) === '') throw new InvalidArgumentException('Action label and ID must not be empty.'); + if (trim($label) === '' || trim($id) === '') throw new InvalidToastConfigurationException('Action label and ID must not be empty.'); return $this->put('action', ['id' => $id, 'label' => $label]); } @@ -143,13 +160,13 @@ public function actionColor(string $color): static public function cornerRadius(float $radius): static { - if ($radius < 0) throw new InvalidArgumentException('Corner radius must not be negative.'); + if ($radius < 0) throw new InvalidToastConfigurationException('Corner radius must not be negative.'); return $this->putStyle('corner_radius', $radius); } public function padding(float $padding): static { - if ($padding < 0) throw new InvalidArgumentException('Padding must not be negative.'); + if ($padding < 0) throw new InvalidToastConfigurationException('Padding must not be negative.'); return $this->putStyle('padding', $padding); } @@ -173,14 +190,14 @@ public function strategy(ToastStrategy|string $strategy): static public function maxVisible(int $count): static { - if ($count < 1) throw new InvalidArgumentException('Maximum visible toasts must be at least one.'); + if ($count < 1) throw new InvalidToastConfigurationException('Maximum visible toasts must be at least one.'); return $this->put('max_visible', $count); } private function color(string $color): string { if (! preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color)) { - throw new InvalidArgumentException('Colors must use #RGB, #RRGGBB, or #AARRGGBB hexadecimal format.'); + throw new InvalidToastConfigurationException('Colors must use #RGB, #RRGGBB, or #AARRGGBB hexadecimal format.'); } return strtoupper($color); } @@ -189,7 +206,7 @@ private function enumValue(string $enum, BackedEnum|string $value, string $label { if ($value instanceof $enum) return $value->value; $case = $enum::tryFrom($value); - if ($case === null) throw new InvalidArgumentException("Invalid toast {$label}: {$value}."); + if ($case === null) throw new InvalidToastConfigurationException("Invalid toast {$label}: {$value}."); return $case->value; } @@ -216,7 +233,7 @@ private function typography( private function nullableNonEmpty(?string $value, string $label): ?string { - if ($value !== null && trim($value) === '') throw new InvalidArgumentException("{$label} must not be empty."); + if ($value !== null && trim($value) === '') throw new InvalidToastConfigurationException("{$label} must not be empty."); return $value; } -} +} \ No newline at end of file diff --git a/src/Enums/ToastAnimation.php b/src/Enums/ToastAnimation.php index 445090a..324bbe8 100644 --- a/src/Enums/ToastAnimation.php +++ b/src/Enums/ToastAnimation.php @@ -8,4 +8,8 @@ enum ToastAnimation: string case Slide = 'slide'; case Scale = 'scale'; case Spring = 'spring'; -} + case Snap = 'snap'; + case Pop = 'pop'; + case Reveal = 'reveal'; + case Bounce = 'bounce'; +} \ No newline at end of file diff --git a/src/Enums/ToastDirection.php b/src/Enums/ToastDirection.php new file mode 100644 index 0000000..eff92ce --- /dev/null +++ b/src/Enums/ToastDirection.php @@ -0,0 +1,12 @@ +options['message'])) throw new InvalidArgumentException('A toast message is required before show().'); + if (! isset($this->options['message'])) throw new InvalidToastConfigurationException('A toast message is required before show().'); $variant = $this->options['variant'] ?? 'neutral'; $payload = array_replace_recursive( @@ -43,7 +43,7 @@ public function show(): string /** @internal */ public function payload(): array { - if (! isset($this->options['message'])) throw new InvalidArgumentException('A toast message is required before show().'); + if (! isset($this->options['message'])) throw new InvalidToastConfigurationException('A toast message is required before show().'); $variant = $this->options['variant'] ?? 'neutral'; return array_replace_recursive($this->defaults(), $this->variantDefaults($variant), $this->options, [ 'id' => $this->id, @@ -78,6 +78,8 @@ private function defaults(): array 'duration' => 3000, 'persistent' => false, 'animation' => 'scale', + 'direction' => 'auto', + 'loading' => false, 'swipe_to_dismiss' => true, 'dismissible' => false, 'style' => ['corner_radius' => 16, 'padding' => 16, 'shadow' => true], @@ -114,4 +116,4 @@ private static function uuid(): string substr($hex, 20), ); } -} +} \ No newline at end of file diff --git a/src/PendingToastUpdate.php b/src/PendingToastUpdate.php index 9a0b36c..732e81f 100644 --- a/src/PendingToastUpdate.php +++ b/src/PendingToastUpdate.php @@ -2,8 +2,8 @@ namespace Victorycodedev\ToastKit; -use InvalidArgumentException; use Victorycodedev\ToastKit\Concerns\ConfiguresToast; +use Victorycodedev\ToastKit\Exceptions\InvalidToastConfigurationException; class PendingToastUpdate { @@ -15,7 +15,7 @@ public function __construct(private readonly ToastKit $toastKit, private readonl public function show(): string { - if ($this->changes === []) throw new InvalidArgumentException('At least one toast change is required before show().'); + if ($this->changes === []) throw new InvalidToastConfigurationException('At least one toast change is required before show().'); $this->toastKit->applyUpdate($this->id, $this->changes); return $this->id; } diff --git a/src/ToastKit.php b/src/ToastKit.php index 3fb6e12..afaa707 100644 --- a/src/ToastKit.php +++ b/src/ToastKit.php @@ -3,11 +3,14 @@ namespace Victorycodedev\ToastKit; use Closure; -use InvalidArgumentException; +use Victorycodedev\ToastKit\Exceptions\InvalidToastConfigurationException; class ToastKit { - public function __construct(private readonly ?Closure $bridge = null) {} + public function __construct( + private readonly ?Closure $bridge = null, + private readonly ToastPresetRegistry $presets = new ToastPresetRegistry(), + ) {} public function make(?string $message = null): PendingToast { @@ -34,6 +37,16 @@ public function neutral(string $message): PendingToast return $this->make($message)->neutral(); } + public function definePreset(string $name, Closure $preset): void + { + $this->presets->define($name, $preset); + } + + public function preset(string $name): PendingToast + { + return $this->presets->apply($name, $this->make()); + } + public function update(string $id): PendingToastUpdate { $this->assertIdentifier($id); @@ -75,6 +88,6 @@ private function call(string $method, array $parameters): mixed /** @internal */ public function assertIdentifier(string $id): void { - if (trim($id) === '') throw new InvalidArgumentException('Toast IDs must not be empty.'); + if (trim($id) === '') throw new InvalidToastConfigurationException('Toast IDs must not be empty.'); } } diff --git a/src/ToastKitServiceProvider.php b/src/ToastKitServiceProvider.php index b593d73..409203f 100644 --- a/src/ToastKitServiceProvider.php +++ b/src/ToastKitServiceProvider.php @@ -9,7 +9,8 @@ class ToastKitServiceProvider extends ServiceProvider { public function register(): void { - $this->app->singleton(ToastKit::class, fn() => new ToastKit()); + $this->app->singleton(ToastPresetRegistry::class); + $this->app->singleton(ToastKit::class, fn($app) => new ToastKit(presets: $app->make(ToastPresetRegistry::class))); $this->app->alias(ToastKit::class, 'toastkit'); } diff --git a/src/ToastPresetRegistry.php b/src/ToastPresetRegistry.php new file mode 100644 index 0000000..3fe9a87 --- /dev/null +++ b/src/ToastPresetRegistry.php @@ -0,0 +1,30 @@ + */ + private array $presets = []; + + public function define(string $name, Closure $preset): void + { + $name = trim($name); + if ($name === '') throw new InvalidToastConfigurationException('Toast preset names must not be empty.'); + $this->presets[$name] = $preset; + } + + public function apply(string $name, PendingToast $toast): PendingToast + { + $preset = $this->presets[$name] ?? throw new PresetNotFoundException("Toast preset [{$name}] is not defined."); + $result = $preset($toast); + if ($result !== null && ! $result instanceof PendingToast) { + throw new InvalidToastConfigurationException('A toast preset must return its PendingToast builder or null.'); + } + return $result ?? $toast; + } +} \ No newline at end of file diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index f6d2ac6..55924a3 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -1,6 +1,7 @@ kit->make('Hello')->animation($animation)->show(); expect($this->calls[0][1]['animation'])->toBe($animation); -})->with(['fade', 'slide', 'scale', 'spring']); +})->with(['fade', 'slide', 'scale', 'spring', 'snap', 'pop', 'reveal', 'bounce']); it('accepts a ToastAnimation enum', function () { $this->kit->make('Hello')->animation(ToastAnimation::Slide)->show(); @@ -100,6 +101,26 @@ expect($this->calls[0][1]['animation'])->toBe('slide'); }); +it('sets animation direction independently', function () { + $this->kit->make('Hello')->direction(ToastDirection::Left)->show(); + expect($this->calls[0][1]['direction'])->toBe('left'); +}); + +it('accepts every direction string', function (string $direction) { + $this->kit->make('Hello')->direction($direction)->show(); + expect($this->calls[0][1]['direction'])->toBe($direction); +})->with(['auto', 'left', 'right', 'top', 'bottom']); + +it('clamps determinate progress', function (int|float $input, int|float $expected) { + $this->kit->make('Working')->progress($input)->show(); + expect($this->calls[0][1]['progress'])->toBe($expected); +})->with([[42, 42], [12.5, 12.5], [-2, 0], [120, 100]]); + +it('configures indeterminate loading', function () { + $this->kit->make('Working')->loading()->show(); + expect($this->calls[0][1]['loading'])->toBeTrue(); +}); + it('controls swipe-to-dismiss independently', function () { $this->kit->make('Hello')->swipeToDismiss(false)->show(); diff --git a/tests/Unit/PresetTest.php b/tests/Unit/PresetTest.php new file mode 100644 index 0000000..6e2f9b4 --- /dev/null +++ b/tests/Unit/PresetTest.php @@ -0,0 +1,57 @@ +kit->definePreset('upload', fn(PendingToast $toast) => $toast + ->message('Uploading') + ->info() + ->persistent() + ->loading()); + + $first = $this->kit->preset('upload')->message('First')->show(); + $second = $this->kit->preset('upload')->message('Second')->progress(50)->show(); + + expect($first)->not->toBe($second) + ->and($this->calls[0][1]['message'])->toBe('First') + ->and($this->calls[1][1]['message'])->toBe('Second') + ->and($this->calls[1][1]['progress'])->toBe(50); +}); + +it('uses the last definition when a preset is redefined', function () { + $this->kit->definePreset('status', fn(PendingToast $toast) => $toast->message('Old')); + $this->kit->definePreset('status', fn(PendingToast $toast) => $toast->message('New')); + $this->kit->preset('status')->show(); + expect($this->calls[0][1]['message'])->toBe('New'); +}); + +it('throws package exceptions for invalid presets', function () { + expect(fn() => $this->kit->preset('missing'))->toThrow(PresetNotFoundException::class) + ->and(fn() => $this->kit->definePreset('', fn($toast) => $toast))->toThrow(InvalidToastConfigurationException::class) + ->and(fn() => $this->kit->definePreset('bad', fn() => 'wrong'))->not->toThrow(InvalidToastConfigurationException::class); + + expect(fn() => $this->kit->preset('bad'))->toThrow(InvalidToastConfigurationException::class); +}); + +it('provides one catchable package exception hierarchy', function () { + expect(new PresetNotFoundException('missing'))->toBeInstanceOf(ToastKitException::class) + ->and(new InvalidToastConfigurationException('invalid'))->toBeInstanceOf(ToastKitException::class) + ->and(new ToastKitException('compatible'))->toBeInstanceOf(InvalidArgumentException::class); +}); + +it('keeps preset configuration while later updates stay sparse', function () { + $this->kit->definePreset('uploading', fn(PendingToast $toast) => $toast + ->message('Uploading')->position('top')->loading()->persistent()); + $id = $this->kit->preset('uploading')->show(); + $this->kit->update($id)->progress(0)->loading(false)->message('Starting')->show(); + + expect($this->calls[0][1]['position'])->toBe('top') + ->and($this->calls[1][1]['changes'])->toBe([ + 'progress' => 0, + 'loading' => false, + 'message' => 'Starting', + ]); +}); diff --git a/tests/Unit/UpdateTest.php b/tests/Unit/UpdateTest.php index 91adeb5..66dfb33 100644 --- a/tests/Unit/UpdateTest.php +++ b/tests/Unit/UpdateTest.php @@ -45,3 +45,11 @@ 'action' => ['id' => 'retry', 'label' => 'Retry'], ]); }); + +it('sends sparse progress and loading updates', function () { + $this->kit->update('upload')->progress(35.5)->show(); + $this->kit->update('upload')->loading(false)->show(); + + expect($this->calls[0][1]['changes'])->toBe(['progress' => 35.5]) + ->and($this->calls[1][1]['changes'])->toBe(['loading' => false]); +}); diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php index 690bc9b..d44b3b2 100644 --- a/tests/Unit/ValidationTest.php +++ b/tests/Unit/ValidationTest.php @@ -17,7 +17,7 @@ }); it('rejects an invalid animation', function () { - expect(fn () => $this->kit->make('x')->animation('bounce'))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->animation('spin'))->toThrow(InvalidArgumentException::class); }); it('rejects an invalid strategy', function () { From 438128cfbb3eb73667a8fcdeb02048511acaa5a2 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Fri, 28 Aug 2026 19:39:18 +0100 Subject: [PATCH 2/5] fix: update toast title font size and text size mappings for consistency across platforms --- .../plugins/toastkit/presentation/ToastKitHost.kt | 12 ++++++------ .../ios/Sources/Presentation/ToastKitHost.swift | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt index ac99897..f5d1596 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -136,7 +136,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { Text( text = title, color = toast.style.foreground, - fontSize = toast.titleText?.size?.let(::toastTextSize) ?: 15.sp, + fontSize = toast.titleText?.size?.let(::toastTextSize) ?: 16.sp, fontWeight = toast.titleText?.weight?.let(::toastTextWeight) ?: FontWeight.SemiBold, fontStyle = toast.titleText?.italic?.let { if (it) FontStyle.Italic else FontStyle.Normal }, fontFamily = toast.titleText?.font?.let(::toastFontFamily), @@ -209,11 +209,11 @@ private fun revealAlignment(toast: ToastKitConfiguration): Alignment.Horizontal } private fun toastTextSize(size: ToastKitTextSize) = when (size) { - ToastKitTextSize.XS -> 11.sp - ToastKitTextSize.SM -> 13.sp - ToastKitTextSize.BASE -> 14.sp - ToastKitTextSize.LG -> 16.sp - ToastKitTextSize.XL -> 18.sp + ToastKitTextSize.XS -> 12.sp + ToastKitTextSize.SM -> 14.sp + ToastKitTextSize.BASE -> 15.sp + ToastKitTextSize.LG -> 17.sp + ToastKitTextSize.XL -> 19.sp } private fun toastTextWeight(weight: ToastKitTextWeight) = when (weight) { diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index c755a0f..e199b9f 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -105,7 +105,7 @@ private struct ToastKitView: View { VStack(alignment: .leading, spacing: 2) { if let title = toast.title { Text(title) - .font(toastKitFont(toast.titleText, defaultSize: 15, defaultWeight: .semibold)) + .font(toastKitFont(toast.titleText, defaultSize: 16, defaultWeight: .semibold)) .applyToastKitTextAlignment(toast.titleText?.align) } Text(toast.message) @@ -215,11 +215,11 @@ private func toastKitFont(_ typography: ToastKitTypographyConfiguration?, defaul private func toastKitFontSize(_ size: ToastKitTextSize) -> CGFloat { switch size { - case .xs: return 11 - case .sm: return 13 - case .base: return 14 - case .lg: return 16 - case .xl: return 18 + case .xs: return 12 + case .sm: return 14 + case .base: return 15 + case .lg: return 17 + case .xl: return 19 } } From 76aa979839f7aaaf40a336d9d3efa78378b59336 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Fri, 28 Aug 2026 19:48:11 +0100 Subject: [PATCH 3/5] feat: add reusable toast presets and service provider integration and detailed explaination in the docs --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f3a191b..5884911 100644 --- a/README.md +++ b/README.md @@ -275,14 +275,23 @@ Toast::success('Published') Define reusable presets during application boot, such as in a service provider: ```php +namespace App\Providers; + +use Illuminate\Support\ServiceProvider; use Victorycodedev\ToastKit\PendingToast; use Victorycodedev\ToastKit\Facades\Toast; -Toast::definePreset('syncing', fn (PendingToast $toast) => $toast - ->message('Syncing...') - ->info() - ->persistent() - ->loading()); +final class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Toast::definePreset('syncing', fn (PendingToast $toast) => $toast + ->message('Syncing...') + ->info() + ->persistent() + ->loading()); + } +} ``` Each call creates a fresh builder, and options chained afterward override the preset: @@ -318,7 +327,67 @@ final class ToastPresets } ``` -Then call `ToastPresets::register()` from your application's `AppServiceProvider::boot()`. +Then call `ToastPresets::register()` from your application's `AppServiceProvider::boot()`: + +```php +namespace App\Providers; + +use App\Toasts\ToastPresets; +use Illuminate\Support\ServiceProvider; + +final class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + ToastPresets::register(); + } +} +``` + +You may instead keep all toast presets in a dedicated service provider, such as `ToastServiceProvider`: + +```php +namespace App\Providers; + +use Illuminate\Support\ServiceProvider; +use Victorycodedev\ToastKit\PendingToast; +use Victorycodedev\ToastKit\Facades\Toast; + +final class ToastServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Toast::definePreset('payment-success', fn (PendingToast $toast) => $toast + ->success() + ->icon('check_circle') + ->animation('reveal') + ->position('top') + ->duration(3000)); + + Toast::definePreset('payment-failed', fn (PendingToast $toast) => $toast + ->error() + ->icon('error') + ->animation('snap') + ->direction('left') + ->position('top') + ->duration(5000)); + + Toast::definePreset('uploading', fn (PendingToast $toast) => $toast + ->info() + ->loading() + ->persistent()); + } +} +``` + +Register that provider using your Laravel application's normal provider registration, for example in `bootstrap/providers.php`: + +```php +return [ + App\Providers\AppServiceProvider::class, + App\Providers\ToastServiceProvider::class, +]; +``` ## Dismissing Toasts From 5d15ef3f738b3cbaa8c6d2557fe2e9cb427d9df0 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Fri, 28 Aug 2026 20:10:17 +0100 Subject: [PATCH 4/5] feat: update exception handling to use ToastKitException hierarchy and improve documentation --- CHANGELOG.md | 1 + README.md | 53 +++++++++++++++++++++++++++- src/Exceptions/ToastKitException.php | 4 +-- tests/Unit/ConfigurationTest.php | 3 +- tests/Unit/CreationTest.php | 8 +++-- tests/Unit/PresetTest.php | 3 +- tests/Unit/TypographyTest.php | 18 +++++----- tests/Unit/ValidationTest.php | 42 +++++++++++----------- 8 files changed, 96 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d5e3a..18da044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,5 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** ToastKit's public exceptions now extend the package-owned `ToastKitException` base, which extends `RuntimeException`. Replace ToastKit-specific `InvalidArgumentException` catches with `ToastKitException`. - Composer requirement raised to PHP `^8.4` and NativePHP Mobile `^4.1`. - Pest upgraded to `^4.0`. diff --git a/README.md b/README.md index 5884911..6aa5fe7 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Each call creates a fresh builder, and options chained afterward override the pr Toast::preset('syncing')->message('Syncing contacts...')->show(); ``` -Defining the same name again replaces the previous definition. Missing presets throw `PresetNotFoundException`. All intentional public configuration failures extend `ToastKitException` (and remain compatible with `InvalidArgumentException`). Presets are PHP-only because they are registered in the Laravel application lifecycle. +Defining the same name again replaces the previous definition. Missing presets throw `PresetNotFoundException`. All intentional public configuration failures extend `ToastKitException`. Presets are PHP-only because they are registered in the Laravel application lifecycle. Small applications can keep these definitions directly in `AppServiceProvider::boot()`. For larger applications, a convenient optional organization is an `App\Toasts\ToastPresets` class with a static `register()` method, called from `AppServiceProvider::boot()`. ToastKit does not generate or require this class; it simply keeps a longer preset catalog out of the provider. Precedence is: ToastKit defaults → preset → per-toast configuration → sparse update configuration. @@ -389,6 +389,57 @@ return [ ]; ``` +### Exceptions + +ToastKit exposes one package-owned exception hierarchy: + +```text +RuntimeException +└── ToastKitException + ├── InvalidToastConfigurationException + └── PresetNotFoundException +``` + +Catch `ToastKitException` when you want to handle every error intentionally raised by ToastKit: + +```php +use Victorycodedev\ToastKit\Exceptions\ToastKitException; + +try { + Toast::preset('payment-success')->message('Paid')->show(); +} catch (ToastKitException $exception) { + report($exception); +} +``` + +Use a specific subclass when the application can recover from one particular condition, such as a missing preset: + +```php +use Victorycodedev\ToastKit\Exceptions\PresetNotFoundException; + +try { + Toast::preset('payment-success'); +} catch (PresetNotFoundException $exception) { + // The requested preset was not registered. +} +``` + +#### Migrating exception catches + +ToastKit exceptions no longer extend PHP's `InvalidArgumentException`. Applications that previously caught that exception for ToastKit calls should use the package base exception instead: + +```php +// Before +catch (\InvalidArgumentException $exception) { + // ... +} + +// Now +catch (\Victorycodedev\ToastKit\Exceptions\ToastKitException $exception) { + // ... +} +``` + ## Dismissing Toasts ```php diff --git a/src/Exceptions/ToastKitException.php b/src/Exceptions/ToastKitException.php index 07430a7..1888642 100644 --- a/src/Exceptions/ToastKitException.php +++ b/src/Exceptions/ToastKitException.php @@ -2,6 +2,6 @@ namespace Victorycodedev\ToastKit\Exceptions; -use InvalidArgumentException; +use RuntimeException; -class ToastKitException extends InvalidArgumentException {} +class ToastKitException extends RuntimeException {} \ No newline at end of file diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 55924a3..2c6a0f9 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -4,6 +4,7 @@ use Victorycodedev\ToastKit\Enums\ToastDirection; use Victorycodedev\ToastKit\Enums\ToastPosition; use Victorycodedev\ToastKit\Enums\ToastStrategy; +use Victorycodedev\ToastKit\Exceptions\InvalidToastConfigurationException; // ── Content ──────────────────────────────────────────────────────────────── @@ -45,7 +46,7 @@ }); it('rejects an icon with no name or overrides', function () { - expect(fn () => $this->kit->make('x')->icon())->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->icon())->toThrow(InvalidToastConfigurationException::class); }); it('sets an action with a label and ID', function () { diff --git a/tests/Unit/CreationTest.php b/tests/Unit/CreationTest.php index 1cd57d2..869a513 100644 --- a/tests/Unit/CreationTest.php +++ b/tests/Unit/CreationTest.php @@ -1,5 +1,7 @@ $this->kit->make()->show())->toThrow(InvalidArgumentException::class, 'message'); + expect(fn () => $this->kit->make()->show())->toThrow(InvalidToastConfigurationException::class, 'message'); }); it('requires a non-empty message', function () { - expect(fn () => $this->kit->make(' '))->toThrow(InvalidArgumentException::class, 'message'); + expect(fn () => $this->kit->make(' '))->toThrow(InvalidToastConfigurationException::class, 'message'); }); it('keeps builder state isolated between toasts', function () { @@ -57,5 +59,5 @@ ]); it('rejects an unknown variant', function () { - expect(fn () => $this->kit->make('x')->variant('danger'))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->variant('danger'))->toThrow(InvalidToastConfigurationException::class); }); diff --git a/tests/Unit/PresetTest.php b/tests/Unit/PresetTest.php index 6e2f9b4..094f0ac 100644 --- a/tests/Unit/PresetTest.php +++ b/tests/Unit/PresetTest.php @@ -39,7 +39,8 @@ it('provides one catchable package exception hierarchy', function () { expect(new PresetNotFoundException('missing'))->toBeInstanceOf(ToastKitException::class) ->and(new InvalidToastConfigurationException('invalid'))->toBeInstanceOf(ToastKitException::class) - ->and(new ToastKitException('compatible'))->toBeInstanceOf(InvalidArgumentException::class); + ->and(new ToastKitException('runtime'))->toBeInstanceOf(RuntimeException::class) + ->and(new ToastKitException('package-owned'))->not->toBeInstanceOf(InvalidArgumentException::class); }); it('keeps preset configuration while later updates stay sparse', function () { diff --git a/tests/Unit/TypographyTest.php b/tests/Unit/TypographyTest.php index 61b2a48..b10efeb 100644 --- a/tests/Unit/TypographyTest.php +++ b/tests/Unit/TypographyTest.php @@ -1,5 +1,7 @@ $this->kit->make('x')->text(size: 'huge'))->toThrow(InvalidArgumentException::class); + expect(fn() => $this->kit->make('x')->text(size: 'huge'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid text weight', function () { - expect(fn() => $this->kit->make('x')->text(weight: 'heavy'))->toThrow(InvalidArgumentException::class); + expect(fn() => $this->kit->make('x')->text(weight: 'heavy'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid text alignment', function () { - expect(fn() => $this->kit->make('x')->text(align: 'justify'))->toThrow(InvalidArgumentException::class); + expect(fn() => $this->kit->make('x')->text(align: 'justify'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an empty font name', function () { - expect(fn() => $this->kit->make('x')->text(font: ''))->toThrow(InvalidArgumentException::class) - ->and(fn() => $this->kit->make('x')->titleText(font: ' '))->toThrow(InvalidArgumentException::class); + expect(fn() => $this->kit->make('x')->text(font: ''))->toThrow(InvalidToastConfigurationException::class) + ->and(fn() => $this->kit->make('x')->titleText(font: ' '))->toThrow(InvalidToastConfigurationException::class); }); it('rejects invalid title typography values', function () { - expect(fn() => $this->kit->make('x')->titleText(size: 'xxl'))->toThrow(InvalidArgumentException::class) - ->and(fn() => $this->kit->make('x')->titleText(weight: 'black'))->toThrow(InvalidArgumentException::class) - ->and(fn() => $this->kit->make('x')->titleText(align: 'middle'))->toThrow(InvalidArgumentException::class); + expect(fn() => $this->kit->make('x')->titleText(size: 'xxl'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn() => $this->kit->make('x')->titleText(weight: 'black'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn() => $this->kit->make('x')->titleText(align: 'middle'))->toThrow(InvalidToastConfigurationException::class); }); it('treats text() with no arguments as a no-op', function () { diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php index d44b3b2..d3ff5ee 100644 --- a/tests/Unit/ValidationTest.php +++ b/tests/Unit/ValidationTest.php @@ -1,57 +1,59 @@ $this->kit->make('x')->background('red'))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->foreground('#12'))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->iconColor('123456'))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->actionColor('#gggggg'))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->background('red'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->foreground('#12'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->iconColor('123456'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->actionColor('#gggggg'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid duration', function () { - expect(fn () => $this->kit->make('x')->duration(0))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->duration(-5))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->duration(0))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->duration(-5))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid position', function () { - expect(fn () => $this->kit->make('x')->position('left'))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->position('left'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid animation', function () { - expect(fn () => $this->kit->make('x')->animation('spin'))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->animation('spin'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid strategy', function () { - expect(fn () => $this->kit->make('x')->strategy('grid'))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->strategy('grid'))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid action', function () { - expect(fn () => $this->kit->make('x')->action('', 'retry'))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->action('Retry', ''))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->action('', 'retry'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->action('Retry', ''))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid custom ID', function () { - expect(fn () => $this->kit->make('x')->id(''))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->id(' '))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->id(''))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->id(' '))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an invalid maxVisible', function () { - expect(fn () => $this->kit->make('x')->maxVisible(0))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->maxVisible(-1))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->maxVisible(0))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->maxVisible(-1))->toThrow(InvalidToastConfigurationException::class); }); it('rejects negative padding and corner radius', function () { - expect(fn () => $this->kit->make('x')->padding(-1))->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->kit->make('x')->cornerRadius(-1))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->make('x')->padding(-1))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->cornerRadius(-1))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an empty dismiss ID', function () { - expect(fn () => $this->kit->dismiss(' '))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->dismiss(' '))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an empty update ID', function () { - expect(fn () => $this->kit->update(' '))->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->update(' '))->toThrow(InvalidToastConfigurationException::class); }); it('rejects an update with no changes', function () { - expect(fn () => $this->kit->update('one')->show())->toThrow(InvalidArgumentException::class); + expect(fn () => $this->kit->update('one')->show())->toThrow(InvalidToastConfigurationException::class); }); From 29a166ee06c8dc2c7e0e6de03b8855bc5bcf8226 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Fri, 28 Aug 2026 20:15:42 +0100 Subject: [PATCH 5/5] docs: clarify Fetch package integration in README and fix formatting --- README.md | 2 +- src/ToastKitServiceProvider.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6aa5fe7..ad2f342 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,7 @@ Blade screen using native components: ## Real-world Example with NativePHP Fetch -ToastKit pairs naturally with a Fetch package for upload/download progress. Fetch is **not** a ToastKit dependency — this is an optional integration example. +ToastKit pairs naturally with the Fetch (Http) package for upload/download progress. Fetch is **not** a ToastKit dependency - this is an optional integration example. ```php $toastId = Toast::info('Downloading...') diff --git a/src/ToastKitServiceProvider.php b/src/ToastKitServiceProvider.php index 409203f..12cd00a 100644 --- a/src/ToastKitServiceProvider.php +++ b/src/ToastKitServiceProvider.php @@ -57,4 +57,4 @@ public static function registerTestMacros(): void return $this->assertCalled('ToastKit.DismissAll'); }); } -} +} \ No newline at end of file