From cc113be2bdb9c4a97762956108ac3836a64f7268 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Wed, 21 Oct 2026 14:58:15 +0100 Subject: [PATCH 1/6] feat: polish toast presentation --- README.md | 18 ++++- .../bridge/ToastKitBridgeNormalizer.kt | 6 ++ .../plugins/toastkit/model/ToastKitModels.kt | 2 + .../toastkit/presentation/ToastKitHost.kt | 67 ++++++++++++++----- .../Bridge/ToastKitBridgeNormalizer.swift | 2 + .../ios/Sources/Model/ToastKitModels.swift | 2 + .../Sources/Presentation/ToastKitHost.swift | 51 +++++++++++--- resources/js/PendingToastBase.js | 3 + resources/js/tests/toastkit.test.js | 8 +++ resources/js/validation.js | 1 + src/Concerns/ConfiguresToast.php | 9 +++ tests/Unit/ConfigurationTest.php | 11 +++ tests/Unit/NativeAppearanceTest.php | 2 + tests/Unit/UpdateTest.php | 6 ++ 14 files changed, 160 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 713766b..599978d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - [Custom Toasts](#custom-toasts) - [Icons](#icons) - [Platform overrides](#platform-overrides) + - [Icon size](#icon-size) - [Typography](#typography) - [Font resolution](#font-resolution) - [Updating Toasts](#updating-toasts) @@ -54,6 +55,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - **Queue strategy** — FIFO, one toast at a time. - **Stack strategy** — up to `maxVisible` toasts on screen with FIFO overflow. - **Live updates** — change a visible or queued toast's message, variant, icon, style, or timer without creating a new toast. +- **Polished updates** — title and message changes crossfade in place instead of snapping. - **Unique toasts** — suppress duplicate work by a stable semantic key across visible, queued, stacked, and persistent toasts. - **Idempotent dismissal** — dismiss by ID or dismiss everything. - **Events** — `ToastShown`, `ToastDismissed`, and `ToastActionPressed`. @@ -142,7 +144,7 @@ Toast::make('Download complete') ->show(); ``` -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. +Supported options include `title()`, `icon()`, `iconSize()`, `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 @@ -179,6 +181,18 @@ You can pass either override on its own: Toast::make('Rated')->icon(ios: 'star.fill')->show(); ``` +### Icon size + +Choose one of three icon sizes for the custom renderer: + +```php +Toast::success('Uploaded') + ->iconSize('lg') // sm, md, or lg + ->show(); +``` + +The size is `18`, `24`, or `32` points/dp respectively. Calling `iconSize()` is optional, so existing toasts retain their current platform default. Platform-native appearance ignores this option because the operating system controls native icon sizing. + See NativePHP's [Icon name reference](https://nativephp.com/docs/mobile/4/edge-components/icon#icon-name-reference) for the names guaranteed to work consistently on both platforms. ## Typography @@ -1013,6 +1027,7 @@ Native::test(ProfileScreen::class) | `success()` / `error()` / `warning()` / `info()` / `neutral()` | Set the variant. | | `variant(ToastVariant\|string $variant)` | Set the variant by enum or string. | | `icon(?string $name = null, ?string $ios = null, ?string $android = null)` | Set an icon using string names, with optional SF Symbol (`ios:`) / Material Icon (`android:`) overrides. See [Icons](#icons). | +| `iconSize(string $size)` | Set custom-renderer icon size to `sm`, `md`, or `lg`; native appearance ignores it. | | `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. | | `native(bool $ios = true, bool $android = true)` | Use platform-native appearance selectively; custom rendering remains the default. | | `opacity(float $opacity = 0.8)` | Set whole-toast opacity from `0.0` to `1.0`; out-of-range values are clamped. | @@ -1067,6 +1082,7 @@ Native::test(ProfileScreen::class) | `animation` | `scale` | | `direction` | `auto` | | `loading` | `false` | +| `icon_size` | Platform default (omitted) | | `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 680d328..c3964c5 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -39,6 +39,7 @@ internal object ToastKitBridgeNormalizer { next = next.copy(variant = name, style = variantStyle(name), icon = defaultIcon(name)) } if (changes.containsKey("icon")) next = next.copy(icon = optionalValue(changes["icon"])?.let(::icon)) + changes["icon_size"]?.let { next = next.copy(iconSize = iconSize(it)) } 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)) } @@ -80,6 +81,7 @@ internal object ToastKitBridgeNormalizer { title = nullableString(values["title"], "title"), variant = variant(values["variant"] ?: "neutral"), icon = optionalValue(values["icon"])?.let(::icon), + iconSize = values["icon_size"]?.let(::iconSize), position = position(values["position"] ?: "bottom"), durationMs = if (persistent) null else positiveLong(values["duration"] ?: 3000, "duration"), animation = animation(values["animation"] ?: "scale"), @@ -105,6 +107,10 @@ internal object ToastKitBridgeNormalizer { return ToastKitIcon(name, android) } + private fun iconSize(value: Any) = ToastKitIconSize.valueOf( + enumString(value, "icon size", setOf("sm", "md", "lg")).uppercase(), + ) + private fun action(value: Any): ToastKitAction { val map = stringMap(value) ?: throw ToastKitInputException("action must be an object") return ToastKitAction(requiredString(map, "id"), requiredString(map, "label")) 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 0addb56..5b40ade 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -9,6 +9,7 @@ internal enum class ToastKitStrategy { QUEUE, STACK } internal enum class ToastKitTextSize { XS, SM, BASE, LG, XL } internal enum class ToastKitTextWeight { NORMAL, MEDIUM, SEMIBOLD, BOLD } internal enum class ToastKitTextAlign { LEFT, CENTER, RIGHT } +internal enum class ToastKitIconSize { SM, MD, LG } internal data class ToastKitIcon(val name: String?, val android: String?) internal data class ToastKitAction(val id: String, val label: String) @@ -39,6 +40,7 @@ internal data class ToastKitConfiguration( val title: String?, val variant: String, val icon: ToastKitIcon?, + val iconSize: ToastKitIconSize?, val position: ToastKitPosition, val durationMs: Long?, val animation: ToastKitAnimation, 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 fe3f155..3b31e43 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -29,6 +28,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import android.view.accessibility.AccessibilityManager import android.content.Context import android.provider.Settings @@ -126,11 +128,12 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { LaunchedEffect(shouldBeVisible) { visibility.targetState = shouldBeVisible } AnimatedVisibility(visibleState = visibility, enter = enter, exit = exit) { val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp).fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Polite } .graphicsLayer { translationX = displayedX; translationY = displayedY; alpha = (1f - (abs(displayedX) + abs(displayedY)) / 700f).coerceIn(.35f, 1f) } .then(toast.opacity?.let { Modifier.alpha(it) } ?: Modifier) .then(gesture) if (toast.nativeAndroid) { - NativeToastKitCard(toast, displayedProgress, interaction) + NativeToastKitCard(toast, displayedProgress, interaction, reduceMotion) } else { Column( modifier = interaction @@ -142,38 +145,49 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { 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) + MaterialIcon(it.android ?: it.name ?: "circle", "Toast icon", size = toastIconSize(toast.iconSize, 24).dp, tint = toast.style.iconColor) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - toast.title?.let { title -> - Text( - text = title, + AnimatedContent( + targetState = toast.title, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "toast-title-update", + ) { displayedTitle -> + displayedTitle?.let { Text( + text = displayedTitle, color = toast.style.foreground, 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), - textAlign = toast.titleText?.align?.let(::toastTextAlign) ?: TextAlign.Left, + textAlign = toast.titleText?.align?.let(::toastTextAlign) ?: TextAlign.Start, modifier = Modifier.fillMaxWidth(), - ) + ) } } - Text( - text = toast.message, + AnimatedContent( + targetState = toast.message, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "toast-message-update", + ) { displayedMessage -> Text( + text = displayedMessage, color = toast.style.foreground, fontSize = toast.text?.size?.let(::toastTextSize) ?: toastTextSize(ToastKitTextSize.BASE), fontWeight = toast.text?.weight?.let(::toastTextWeight) ?: FontWeight.Medium, fontStyle = toast.text?.italic?.let { if (it) FontStyle.Italic else FontStyle.Normal }, fontFamily = toast.text?.font?.let(::toastFontFamily), - textAlign = toast.text?.align?.let(::toastTextAlign) ?: TextAlign.Left, + textAlign = toast.text?.align?.let(::toastTextAlign) ?: TextAlign.Start, modifier = Modifier.fillMaxWidth(), - ) + ) } } toast.action?.let { action -> - Text(action.label, color = toast.style.actionColor, fontWeight = FontWeight.Bold, - modifier = Modifier.pointerInput(action.id) { detectTapGestures { ToastKitManager.action(toast.id, action.id) } }) + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = toast.style.actionColor), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { Text(action.label, fontWeight = FontWeight.Bold) } } if (toast.dismissible) { - Box(Modifier.size(32.dp).pointerInput(toast.id) { detectTapGestures { ToastKitManager.dismiss(toast.id) } }, contentAlignment = Alignment.Center) { + IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { MaterialIcon("close", "Dismiss", size = 20.dp, tint = toast.style.foreground) } } @@ -193,7 +207,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { } @Composable -private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: Float, modifier: Modifier) { +private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: Float, modifier: Modifier, reduceMotion: Boolean) { Snackbar(modifier = modifier) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -203,8 +217,18 @@ private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: MaterialIcon(it.android ?: it.name ?: "circle", "Toast icon", size = 24.dp, tint = LocalContentColor.current) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - toast.title?.let { Text(it, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) } - Text(toast.message, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium) + AnimatedContent( + targetState = toast.title, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "native-toast-title-update", + ) { title -> + title?.let { Text(it, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) } + } + AnimatedContent( + targetState = toast.message, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "native-toast-message-update", + ) { Text(it, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium) } } toast.action?.let { action -> TextButton( @@ -276,5 +300,12 @@ private fun toastTextAlign(align: ToastKitTextAlign) = when (align) { ToastKitTextAlign.RIGHT -> TextAlign.Right } +private fun toastIconSize(size: ToastKitIconSize?, fallback: Int): Int = when (size) { + ToastKitIconSize.SM -> 18 + ToastKitIconSize.MD -> 24 + ToastKitIconSize.LG -> 32 + null -> fallback +} + private fun toastFontFamily(font: String): FontFamily? = NativeUIThemeProvider.resolveChromeFontFamily(font) diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index fe56862..42d8390 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -42,6 +42,7 @@ enum ToastKitBridgeNormalizer { next.icon = defaultIcon(name) } if let value = changes["icon"] { next.icon = try icon(value) } + if let value = changes["icon_size"] { next.iconSize = try enumValue(value, "icon size") } 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") } @@ -82,6 +83,7 @@ enum ToastKitBridgeNormalizer { title: try nullableString(values["title"], "title"), variant: variantName, icon: try values["icon"].map(icon), + iconSize: try values["icon_size"].map { try enumValue($0, "icon size") }, position: try enumValue(values["position"] ?? "bottom", "position"), durationMilliseconds: persistent ? nil : try positiveInt(values["duration"] ?? 3000, "duration"), animation: try enumValue(values["animation"] ?? "scale", "animation"), diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index 1be42fd..7670ea6 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -7,6 +7,7 @@ enum ToastKitStrategy: String { case queue, stack } enum ToastKitTextSize: String { case xs, sm, base, lg, xl } enum ToastKitTextWeight: String { case normal, medium, semibold, bold } enum ToastKitTextAlign: String { case left, center, right } +enum ToastKitIconSize: String { case sm, md, lg } struct ToastKitIconConfiguration { let name: String? @@ -46,6 +47,7 @@ struct ToastKitConfiguration: Identifiable { var title: String? var variant: String var icon: ToastKitIconConfiguration? + var iconSize: ToastKitIconSize? var position: ToastKitPosition var durationMilliseconds: Int? var animation: ToastKitAnimationKind diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index d5caaff..1490bfc 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -110,6 +110,7 @@ private struct ToastKitView: View { } } .toastKitConditional(toast.swipeToDismiss) { $0.gesture(dragGesture) } + .accessibilityElement(children: .contain) .animation(reduceMotion ? .linear(duration: 0.1) : .spring(response: 0.3, dampingFraction: 0.78), value: drag) } @@ -119,19 +120,26 @@ private struct ToastKitView: View { 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) + .font(.system(size: toastKitIconSize(toast.iconSize, fallback: 21), weight: .semibold)).foregroundStyle(toast.style.iconColor) .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { - if let title = toast.title { - Text(title) - .font(toastKitFont(toast.titleText, defaultSize: 16, defaultWeight: .semibold)) - .applyToastKitTextAlignment(toast.titleText?.align) + Group { + if let title = toast.title { + Text(title) + .font(toastKitFont(toast.titleText, defaultSize: 16, defaultWeight: .semibold)) + .applyToastKitTextAlignment(toast.titleText?.align) + .contentTransition(.opacity) + } } + .transition(.opacity) + .animation(contentUpdateAnimation, value: toast.title) Text(toast.message) .font(toastKitFont(toast.text, defaultSize: toastKitFontSize(.base), defaultWeight: .medium)) .applyToastKitTextAlignment(toast.text?.align) .fixedSize(horizontal: false, vertical: true) + .contentTransition(.opacity) + .animation(contentUpdateAnimation, value: toast.message) } .foregroundStyle(toast.style.foreground).frame(maxWidth: .infinity, alignment: .leading) if let action = toast.action { @@ -140,7 +148,7 @@ private struct ToastKitView: View { } if toast.dismissible { Button { ToastKitManager.shared.dismiss(toast.id) } label: { - Image(systemName: "xmark").font(.system(size: 13, weight: .bold)).frame(width: 28, height: 28) + Image(systemName: "xmark").font(.system(size: 13, weight: .bold)).frame(minWidth: 44, minHeight: 44) } .foregroundStyle(toast.style.foreground).accessibilityLabel("Dismiss") } @@ -169,8 +177,20 @@ private struct ToastKitView: View { .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { - if let title = toast.title { Text(title).font(.headline) } - Text(toast.message).font(.subheadline).fixedSize(horizontal: false, vertical: true) + Group { + if let title = toast.title { + Text(title) + .font(.headline) + .contentTransition(.opacity) + } + } + .transition(.opacity) + .animation(contentUpdateAnimation, value: toast.title) + Text(toast.message) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + .contentTransition(.opacity) + .animation(contentUpdateAnimation, value: toast.message) } .frame(maxWidth: .infinity, alignment: .leading) if let action = toast.action { @@ -180,7 +200,7 @@ private struct ToastKitView: View { } if toast.dismissible { Button { ToastKitManager.shared.dismiss(toast.id) } label: { - Image(systemName: "xmark").frame(width: 28, height: 28) + Image(systemName: "xmark").frame(minWidth: 44, minHeight: 44) } .buttonStyle(.plain) .accessibilityLabel("Dismiss") @@ -214,6 +234,10 @@ private struct ToastKitView: View { else { drag = .zero } } } + + private var contentUpdateAnimation: Animation { + reduceMotion ? .linear(duration: 0.01) : .easeInOut(duration: 0.16) + } } private struct ToastKitFramePreferenceKey: PreferenceKey { @@ -283,6 +307,15 @@ private func toastKitFontSize(_ size: ToastKitTextSize) -> CGFloat { } } +private func toastKitIconSize(_ size: ToastKitIconSize?, fallback: CGFloat) -> CGFloat { + switch size { + case .sm: return 18 + case .md: return 24 + case .lg: return 32 + case nil: return fallback + } +} + private func toastKitFontWeight(_ weight: ToastKitTextWeight) -> Font.Weight { switch weight { case .normal: return .regular diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index 84d2a63..e201d74 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -52,6 +52,9 @@ export class PendingToastBase { throw new TypeError("An icon name or platform override is required"); return this.set("icon", icon); } + iconSize(value) { + return this.set("icon_size", oneOf(value, "icon size")); + } position(value) { return this.set("position", oneOf(value, "position")); } diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index b5668bf..7eb5bd3 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -72,6 +72,14 @@ test("icon supports logical names and platform overrides", () => { ); }); +test("icon size accepts only predefined sizes", () => { + assert.equal(Toast.make("x").iconSize("lg").payload().icon_size, "lg"); + assert.deepEqual(Toast.update("one").iconSize("sm").payload().changes, { + icon_size: "sm", + }); + assert.throws(() => Toast.make("x").iconSize("xl"), /icon size/); +}); + test("position, duration, persistent and animation are set", () => { const payload = Toast.make("x") .position("top") diff --git a/resources/js/validation.js b/resources/js/validation.js index b17e930..562f769 100644 --- a/resources/js/validation.js +++ b/resources/js/validation.js @@ -7,6 +7,7 @@ export const allowed = { size: ["xs", "sm", "base", "lg", "xl"], weight: ["normal", "medium", "semibold", "bold"], align: ["left", "center", "right"], + "icon size": ["sm", "md", "lg"], }; export function nonEmpty(value, name) { if (typeof value !== "string" || value.trim() === "") diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 6ea92e0..535ebc7 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -87,6 +87,15 @@ public function icon(?string $name = null, ?string $ios = null, ?string $android return $this->put('icon', $icon); } + public function iconSize(string $size): static + { + if (! in_array($size, ['sm', 'md', 'lg'], true)) { + throw new InvalidToastConfigurationException("Invalid toast icon size: {$size}."); + } + + return $this->put('icon_size', $size); + } + public function position(ToastPosition|string $position): static { return $this->put('position', $this->enumValue(ToastPosition::class, $position, 'position')); diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 2c6a0f9..921a762 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -49,6 +49,17 @@ expect(fn () => $this->kit->make('x')->icon())->toThrow(InvalidToastConfigurationException::class); }); +it('sets a predefined icon size', function (string $size) { + $this->kit->make('Saved')->icon('check')->iconSize($size)->show(); + + expect($this->calls[0][1]['icon_size'])->toBe($size); +})->with(['sm', 'md', 'lg']); + +it('rejects an invalid icon size', function () { + expect(fn () => $this->kit->make('x')->iconSize('xl')) + ->toThrow(InvalidToastConfigurationException::class); +}); + it('sets an action with a label and ID', function () { $this->kit->make('Failed')->action('Retry', 'retry')->show(); diff --git a/tests/Unit/NativeAppearanceTest.php b/tests/Unit/NativeAppearanceTest.php index 03d913b..fe28354 100644 --- a/tests/Unit/NativeAppearanceTest.php +++ b/tests/Unit/NativeAppearanceTest.php @@ -39,6 +39,7 @@ ->title('Success') ->success() ->icon(ios: 'checkmark.seal', android: 'verified') + ->iconSize('lg') ->action('View', 'view') ->position('top') ->persistent() @@ -68,6 +69,7 @@ 'direction' => 'top', ])->and($payload['icon']['ios'])->toBe('checkmark.seal') ->and($payload['icon']['android'])->toBe('verified') + ->and($payload['icon_size'])->toBe('lg') ->and($payload['style']['background'])->toBe('#000000') ->and($payload['text'])->toBe(['size' => 'sm']); }); diff --git a/tests/Unit/UpdateTest.php b/tests/Unit/UpdateTest.php index 66dfb33..d803794 100644 --- a/tests/Unit/UpdateTest.php +++ b/tests/Unit/UpdateTest.php @@ -37,6 +37,12 @@ ]); }); +it('updates icon size sparsely', function () { + $this->kit->update('upload')->iconSize('lg')->show(); + + expect($this->calls[0][1]['changes'])->toBe(['icon_size' => 'lg']); +}); + it('updates title and action together', function () { $this->kit->update('upload')->title('Done')->action('Retry', 'retry')->show(); From dec833138c789f3e6932772bdf91892a732bdbba Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Wed, 21 Oct 2026 15:48:58 +0100 Subject: [PATCH 2/6] feat: add dynamic toast presentation --- README.md | 34 +++- .../bridge/ToastKitBridgeNormalizer.kt | 3 + .../plugins/toastkit/model/ToastKitModels.kt | 2 + .../toastkit/presentation/ToastKitHost.kt | 189 ++++++++++++++++-- .../Bridge/ToastKitBridgeNormalizer.swift | 2 + .../ios/Sources/Model/ToastKitModels.swift | 2 + .../Sources/Presentation/ToastKitHost.swift | 183 +++++++++++++++-- resources/js/PendingToast.js | 1 + resources/js/PendingToastBase.js | 6 + resources/js/tests/toastkit.test.js | 10 + resources/js/validation.js | 1 + src/Concerns/ConfiguresToast.php | 11 + src/Enums/ToastPresentation.php | 9 + src/PendingToast.php | 1 + tests/Unit/BridgeTest.php | 1 + tests/Unit/PresentationTest.php | 40 ++++ 16 files changed, 463 insertions(+), 32 deletions(-) create mode 100644 src/Enums/ToastPresentation.php create mode 100644 tests/Unit/PresentationTest.php diff --git a/README.md b/README.md index 599978d..25d58f9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - [Updating Toasts](#updating-toasts) - [Unique Toasts](#unique-toasts) - [Native Appearance](#native-appearance) + - [Dynamic Presentation](#dynamic-presentation) - [Opacity](#opacity) - [Progress \& Loading](#progress--loading) - [Animations \& Direction](#animations--direction) @@ -144,7 +145,7 @@ Toast::make('Download complete') ->show(); ``` -Supported options include `title()`, `icon()`, `iconSize()`, `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. +Supported options include `title()`, `icon()`, `iconSize()`, `position()`, `presentation()`, `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 @@ -401,6 +402,33 @@ Toast::update($id) On iOS 26 and later, ToastKit uses Apple's system Liquid Glass effect. Earlier supported iOS versions use SwiftUI's native regular material. Android uses the official Material 3 `Snackbar` component inside ToastKit's existing overlay, retaining the established lifecycle and event pipeline. +## Dynamic Presentation + +Dynamic presentation flows from the top camera area with a short liquid-drop connector, starts as a compact foreground pill, and smoothly expands to reveal the toast content. It retracts toward the top when dismissed. The effect has a native SwiftUI design on iOS and a Material-style Compose design on Android: + +```php +Toast::info('Syncing contacts...') + ->dynamic() + ->loading() + ->persistent() + ->show(); +``` + +`dynamic()` is a shortcut for `presentation('dynamic')`. Use `presentation('standard')` to select the regular toast explicitly: + +```php +Toast::success('Contacts synced') + ->presentation('dynamic') + ->progress(100) + ->action('View', 'view-contacts') + ->dismissible() + ->show(); +``` + +Dynamic presentation works on iOS and Android and retains the normal toast lifecycle: entrance and exit animations, duration, persistence, queue/stack behavior, unique keys, live updates, progress, loading, one action, close controls, swipe dismissal, opacity, colors, typography, and icon sizing all remain available. It is automatically anchored at the top, so callers do not need to add `position('top')`. + +The dynamic renderer controls its placement, so `position()` and `direction()` are ignored.`native()` is also ignored while dynamic presentation is selected because dynamic presentation is already a platform-specific native design. The retained placement and native settings apply again if a sparse update changes the presentation back to `standard`. + ## Opacity Opacity is optional and applies to the entire toast, including its content and controls: @@ -1029,6 +1057,8 @@ Native::test(ProfileScreen::class) | `icon(?string $name = null, ?string $ios = null, ?string $android = null)` | Set an icon using string names, with optional SF Symbol (`ios:`) / Material Icon (`android:`) overrides. See [Icons](#icons). | | `iconSize(string $size)` | Set custom-renderer icon size to `sm`, `md`, or `lg`; native appearance ignores it. | | `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. | +| `presentation(ToastPresentation\|string $presentation)` | Select `standard` or `dynamic` presentation. | +| `dynamic()` | Shortcut for `presentation('dynamic')`. | | `native(bool $ios = true, bool $android = true)` | Use platform-native appearance selectively; custom rendering remains the default. | | `opacity(float $opacity = 0.8)` | Set whole-toast opacity from `0.0` to `1.0`; out-of-range values are clamped. | | `duration(int $milliseconds)` | Set the visible duration (makes the toast timed). | @@ -1063,6 +1093,7 @@ Native::test(ProfileScreen::class) | --- | --- | | `ToastVariant` | `success`, `error`, `warning`, `info`, `neutral` | | `ToastPosition` | `top`, `center`, `bottom` | +| `ToastPresentation` | `standard`, `dynamic` | | `ToastAnimation` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, `bounce` | | `ToastDirection` | `auto`, `left`, `right`, `top`, `bottom` | | `ToastStrategy` | `queue`, `stack` | @@ -1077,6 +1108,7 @@ Native::test(ProfileScreen::class) | --- | --- | | `variant` | `neutral` | | `position` | `bottom` | +| `presentation` | `standard` | | `duration` | `3000` ms | | `persistent` | `false` | | `animation` | `scale` | 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 c3964c5..54ffe1a 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -41,6 +41,7 @@ internal object ToastKitBridgeNormalizer { if (changes.containsKey("icon")) next = next.copy(icon = optionalValue(changes["icon"])?.let(::icon)) changes["icon_size"]?.let { next = next.copy(iconSize = iconSize(it)) } changes["position"]?.let { next = next.copy(position = position(it)) } + changes["presentation"]?.let { next = next.copy(presentation = presentation(it)) } changes["animation"]?.let { next = next.copy(animation = animation(it)) } changes["direction"]?.let { next = next.copy(direction = direction(it)) } changes["native"]?.let { @@ -83,6 +84,7 @@ internal object ToastKitBridgeNormalizer { icon = optionalValue(values["icon"])?.let(::icon), iconSize = values["icon_size"]?.let(::iconSize), position = position(values["position"] ?: "bottom"), + presentation = presentation(values["presentation"] ?: "standard"), durationMs = if (persistent) null else positiveLong(values["duration"] ?: 3000, "duration"), animation = animation(values["animation"] ?: "scale"), direction = direction(values["direction"] ?: "auto"), @@ -195,6 +197,7 @@ 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 presentation(value: Any) = ToastKitPresentation.valueOf(enumString(value, "presentation", setOf("standard", "dynamic")).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()) 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 5b40ade..95d59ac 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -3,6 +3,7 @@ package com.victorycodedev.plugins.toastkit.model import androidx.compose.ui.graphics.Color internal enum class ToastKitPosition { TOP, CENTER, BOTTOM } +internal enum class ToastKitPresentation { STANDARD, DYNAMIC } 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 } @@ -42,6 +43,7 @@ internal data class ToastKitConfiguration( val icon: ToastKitIcon?, val iconSize: ToastKitIconSize?, val position: ToastKitPosition, + val presentation: ToastKitPresentation, val durationMs: Long?, val animation: ToastKitAnimation, val direction: ToastKitDirection, 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 3b31e43..29f26f5 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -5,10 +5,12 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.GenericShape import androidx.compose.material3.Text import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -17,6 +19,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -25,6 +28,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.LocalContext @@ -45,10 +49,11 @@ import com.nativephp.mobile.ui.NativeUIThemeProvider import com.victorycodedev.plugins.toastkit.manager.ToastKitManager import com.victorycodedev.plugins.toastkit.model.* import kotlin.math.abs +import kotlinx.coroutines.delay @Composable internal fun ToastKitHost(position: ToastKitPosition) { - val items = ToastKitManager.rendered.filter { it.position == position } + val items = ToastKitManager.rendered.filter { effectivePosition(it) == position } val vertical = when (position) { ToastKitPosition.TOP -> Arrangement.Top; ToastKitPosition.CENTER -> Arrangement.Center; ToastKitPosition.BOTTOM -> Arrangement.Bottom } val safeArea = when (position) { ToastKitPosition.TOP -> Modifier.statusBarsPadding() @@ -68,6 +73,7 @@ internal fun ToastKitHost(position: ToastKitPosition) { @Composable private fun ToastKitCard(toast: ToastKitConfiguration) { + val position = effectivePosition(toast) var x by remember(toast.id) { mutableFloatStateOf(0f) } var y by remember(toast.id) { mutableFloatStateOf(0f) } var settling by remember(toast.id) { mutableStateOf(false) } @@ -82,13 +88,13 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { animationSpec = if (reduceMotion) tween(100) else tween(220), label = "toast-progress", ) - val gesture = if (!toast.swipeToDismiss) Modifier else Modifier.pointerInput(toast.id) { + val gesture = if (!toast.swipeToDismiss) Modifier else Modifier.pointerInput(toast.id, position) { detectDragGestures( onDragStart = { velocityTracker.resetTracking() }, onDragEnd = { - val distance = if (toast.position == ToastKitPosition.CENTER) abs(x) else abs(y) + val distance = if (position == ToastKitPosition.CENTER) abs(x) else abs(y) val velocity = velocityTracker.calculateVelocity() - val speed = if (toast.position == ToastKitPosition.CENTER) abs(velocity.x) else abs(velocity.y) + val speed = if (position == ToastKitPosition.CENTER) abs(velocity.x) else abs(velocity.y) if (distance > 80.dp.toPx() || speed > 900f) ToastKitManager.dismiss(toast.id, "swipe") else { settling = true; x = 0f; y = 0f } }, onDragCancel = { settling = true; x = 0f; y = 0f }, @@ -96,14 +102,15 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { change.consume() velocityTracker.addPosition(change.uptimeMillis, change.position) settling = false - when (toast.position) { + when (position) { ToastKitPosition.TOP -> y = (y + amount.y).coerceAtMost(0f) ToastKitPosition.BOTTOM -> y = (y + amount.y).coerceAtLeast(0f) ToastKitPosition.CENTER -> x += amount.x } } } - val enter = if (reduceMotion || toast.nativeAndroid) fadeIn(tween(120)) else when (toast.animation) { + val usesNativeStandard = toast.nativeAndroid && toast.presentation == ToastKitPresentation.STANDARD + val enter = if (reduceMotion || usesNativeStandard) fadeIn(tween(120)) else when (toast.animation) { ToastKitAnimation.FADE -> fadeIn(tween(180)) ToastKitAnimation.SLIDE -> directionalEnter(toast, tween(220)) + fadeIn() ToastKitAnimation.SCALE -> scaleIn(tween(180), initialScale = .9f) + fadeIn() @@ -113,7 +120,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { 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 || toast.nativeAndroid) fadeOut(tween(100)) else when (toast.animation) { + val exit = if (reduceMotion || usesNativeStandard) fadeOut(tween(100)) else when (toast.animation) { ToastKitAnimation.FADE -> fadeOut(tween(160)) ToastKitAnimation.SLIDE -> directionalExit(toast, tween(220)) + fadeOut() ToastKitAnimation.SCALE -> scaleOut(tween(160), targetScale = .9f) + fadeOut() @@ -127,12 +134,15 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { val shouldBeVisible = toast.id !in ToastKitManager.exiting LaunchedEffect(shouldBeVisible) { visibility.targetState = shouldBeVisible } AnimatedVisibility(visibleState = visibility, enter = enter, exit = exit) { - val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp).fillMaxWidth() + val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp) + .then(if (toast.presentation == ToastKitPresentation.STANDARD) Modifier.fillMaxWidth() else Modifier) .semantics { liveRegion = LiveRegionMode.Polite } .graphicsLayer { translationX = displayedX; translationY = displayedY; alpha = (1f - (abs(displayedX) + abs(displayedY)) / 700f).coerceIn(.35f, 1f) } .then(toast.opacity?.let { Modifier.alpha(it) } ?: Modifier) .then(gesture) - if (toast.nativeAndroid) { + if (toast.presentation == ToastKitPresentation.DYNAMIC) { + DynamicToastKitCard(toast, displayedProgress, interaction, reduceMotion, shouldBeVisible) + } else if (toast.nativeAndroid) { NativeToastKitCard(toast, displayedProgress, interaction, reduceMotion) } else { Column( @@ -206,6 +216,149 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { } } +@Composable +private fun DynamicToastKitCard( + toast: ToastKitConfiguration, + displayedProgress: Float, + modifier: Modifier, + reduceMotion: Boolean, + visible: Boolean, +) { + var expanded by remember(toast.id) { mutableStateOf(false) } + var dropExtended by remember(toast.id) { mutableStateOf(false) } + LaunchedEffect(visible) { + if (visible) { + dropExtended = true + if (!reduceMotion) delay(70) + expanded = true + } else { + expanded = false + if (!reduceMotion) delay(45) + dropExtended = false + } + } + val dropScale by animateFloatAsState( + targetValue = if (dropExtended) 1f else 0f, + animationSpec = if (reduceMotion) tween(1) else spring(dampingRatio = .72f, stiffness = 620f), + label = "dynamic-toast-drop", + ) + + Box( + modifier = modifier.then(if (expanded) Modifier.fillMaxWidth() else Modifier), + contentAlignment = Alignment.TopCenter, + ) { + Box( + Modifier + .offset(y = (-20).dp) + .size(width = 20.dp, height = 26.dp) + .graphicsLayer { + scaleY = dropScale + transformOrigin = TransformOrigin(0.5f, 0f) + } + .background(toast.style.background, DynamicToastDropShape), + ) + Surface( + modifier = Modifier + .then(if (expanded) Modifier.fillMaxWidth() else Modifier) + .animateContentSize( + animationSpec = if (reduceMotion) tween(1) else spring(dampingRatio = .78f, stiffness = 520f), + ), + shape = RoundedCornerShape(toast.style.cornerRadius.dp), + color = toast.style.background, + contentColor = toast.style.foreground, + shadowElevation = if (toast.style.shadow) 10.dp else 0.dp, + ) { + Column { + Row( + modifier = Modifier.padding(toast.style.padding.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (toast.progress == null && toast.loading) { + CircularProgressIndicator(Modifier.size(22.dp), color = toast.style.iconColor, strokeWidth = 2.5.dp) + } else { + val icon = toast.icon + MaterialIcon( + icon?.android ?: icon?.name ?: "notifications", + "Toast notification", + size = toastIconSize(toast.iconSize, 22).dp, + tint = toast.style.iconColor, + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Column(Modifier.widthIn(min = 120.dp, max = 320.dp).weight(1f, fill = false)) { + AnimatedContent( + targetState = toast.title, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "dynamic-toast-title-update", + ) { title -> + title?.let { Text( + title, + color = toast.style.foreground, + fontSize = toast.titleText?.size?.let(::toastTextSize) ?: 16.sp, + fontWeight = toast.titleText?.weight?.let(::toastTextWeight) ?: FontWeight.SemiBold, + fontStyle = toast.titleText?.italic?.let { italic -> if (italic) FontStyle.Italic else FontStyle.Normal }, + fontFamily = toast.titleText?.font?.let(::toastFontFamily), + maxLines = 1, + ) } + } + AnimatedContent( + targetState = toast.message, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "dynamic-toast-message-update", + ) { message -> Text( + message, + color = toast.style.foreground, + fontSize = toast.text?.size?.let(::toastTextSize) ?: toastTextSize(ToastKitTextSize.BASE), + fontWeight = toast.text?.weight?.let(::toastTextWeight) ?: FontWeight.Medium, + fontStyle = toast.text?.italic?.let { if (it) FontStyle.Italic else FontStyle.Normal }, + fontFamily = toast.text?.font?.let(::toastFontFamily), + maxLines = 2, + ) } + } + toast.action?.let { action -> + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = toast.style.actionColor), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { Text(action.label, fontWeight = FontWeight.Bold, maxLines = 1) } + } + if (toast.dismissible) { + IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { + MaterialIcon("close", "Dismiss", size = 20.dp, tint = toast.style.foreground) + } + } + } + } + } + if (expanded && toast.progress != null) { + LinearProgressIndicator( + progress = { displayedProgress }, + modifier = Modifier.fillMaxWidth().height(3.dp), + color = toast.style.actionColor, + trackColor = toast.style.foreground.copy(alpha = .22f), + ) + } + } + } + } +} + +private val DynamicToastDropShape = GenericShape { size, _ -> + moveTo(size.width * .36f, 0f) + cubicTo(size.width * .38f, size.height * .38f, size.width * .30f, size.height * .48f, size.width * .15f, size.height * .66f) + cubicTo(0f, size.height * .84f, size.width * .18f, size.height, size.width * .5f, size.height) + cubicTo(size.width * .82f, size.height, size.width, size.height * .84f, size.width * .85f, size.height * .66f) + cubicTo(size.width * .70f, size.height * .48f, size.width * .62f, size.height * .38f, size.width * .64f, 0f) + close() +} + @Composable private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: Float, modifier: Modifier, reduceMotion: Boolean) { Snackbar(modifier = modifier) { @@ -249,14 +402,18 @@ private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: } } -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 +private fun resolvedDirection(toast: ToastKitConfiguration): ToastKitDirection = + if (toast.presentation == ToastKitPresentation.DYNAMIC) ToastKitDirection.TOP else when (toast.direction) { + ToastKitDirection.AUTO -> when (effectivePosition(toast)) { + ToastKitPosition.TOP -> ToastKitDirection.TOP + ToastKitPosition.BOTTOM -> ToastKitDirection.BOTTOM + ToastKitPosition.CENTER -> ToastKitDirection.TOP + } + else -> toast.direction } - else -> toast.direction -} + +private fun effectivePosition(toast: ToastKitConfiguration): ToastKitPosition = + if (toast.presentation == ToastKitPresentation.DYNAMIC) ToastKitPosition.TOP else toast.position private fun directionalEnter(toast: ToastKitConfiguration, spec: androidx.compose.animation.core.FiniteAnimationSpec): EnterTransition = when (resolvedDirection(toast)) { diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index 42d8390..fe82655 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -44,6 +44,7 @@ enum ToastKitBridgeNormalizer { if let value = changes["icon"] { next.icon = try icon(value) } if let value = changes["icon_size"] { next.iconSize = try enumValue(value, "icon size") } if let value = changes["position"] { next.position = try enumValue(value, "position") } + if let value = changes["presentation"] { next.presentation = try enumValue(value, "presentation") } if let value = changes["animation"] { next.animation = try enumValue(value, "animation") } if let value = changes["direction"] { next.direction = try enumValue(value, "direction") } if let value = changes["native"] { @@ -85,6 +86,7 @@ enum ToastKitBridgeNormalizer { icon: try values["icon"].map(icon), iconSize: try values["icon_size"].map { try enumValue($0, "icon size") }, position: try enumValue(values["position"] ?? "bottom", "position"), + presentation: try enumValue(values["presentation"] ?? "standard", "presentation"), durationMilliseconds: persistent ? nil : try positiveInt(values["duration"] ?? 3000, "duration"), animation: try enumValue(values["animation"] ?? "scale", "animation"), direction: try enumValue(values["direction"] ?? "auto", "direction"), diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index 7670ea6..bb8bd1e 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -1,6 +1,7 @@ import SwiftUI enum ToastKitPosition: String { case top, center, bottom } +enum ToastKitPresentation: String { case standard, dynamic } 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 } @@ -49,6 +50,7 @@ struct ToastKitConfiguration: Identifiable { var icon: ToastKitIconConfiguration? var iconSize: ToastKitIconSize? var position: ToastKitPosition + var presentation: ToastKitPresentation var durationMilliseconds: Int? var animation: ToastKitAnimationKind var direction: ToastKitDirection diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index 1490bfc..aaf7d79 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -25,7 +25,7 @@ struct ToastKitHost: View { @ViewBuilder private func host(for position: ToastKitPosition, alignment: Alignment) -> some View { VStack(spacing: 8) { - ForEach(manager.visible.filter { $0.position == position }) { toast in + ForEach(manager.visible.filter { effectivePosition(for: $0) == position }) { toast in ToastKitView(toast: toast, reduceMotion: reduceMotion) .transition(transition(for: toast)) } @@ -35,21 +35,27 @@ struct ToastKitHost: View { } private func transition(for toast: ToastKitConfiguration) -> AnyTransition { - if reduceMotion || toast.nativeIos { return .opacity } + if reduceMotion || (toast.nativeIos && toast.presentation == .standard) { return .opacity } + let transition: AnyTransition switch toast.animation { - case .fade: return .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 .fade: transition = .opacity + case .slide: transition = .move(edge: edge(for: toast)).combined(with: .opacity) + case .scale: transition = .scale(scale: 0.9).combined(with: .opacity) + case .spring: transition = .scale(scale: 0.86).combined(with: .opacity) + case .snap: transition = .move(edge: edge(for: toast)).combined(with: .opacity) + case .pop: transition = .scale(scale: 0.72).combined(with: .opacity) case .reveal: - return .modifier( + transition = .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) + case .bounce: transition = .move(edge: edge(for: toast)).combined(with: .opacity) } + guard toast.presentation == .dynamic else { return transition } + return transition.combined(with: .modifier( + active: ToastKitDynamicPillModifier(amount: 0.18), + identity: ToastKitDynamicPillModifier(amount: 1) + )) } private func edge(for toast: ToastKitConfiguration) -> Edge { @@ -71,14 +77,23 @@ struct ToastKitHost: View { } private func resolvedDirection(for toast: ToastKitConfiguration) -> ToastKitDirection { + if toast.presentation == .dynamic { return .top } guard toast.direction == .auto else { return toast.direction } - switch toast.position { + switch effectivePosition(for: toast) { case .top: return .top case .bottom, .center: return .bottom } } } +private struct ToastKitDynamicPillModifier: ViewModifier { + let amount: CGFloat + + func body(content: Content) -> some View { + content.scaleEffect(x: amount, y: 1).clipped() + } +} + private struct ToastKitRevealModifier: ViewModifier { let amount: CGFloat let anchor: UnitPoint @@ -92,10 +107,18 @@ private struct ToastKitView: View { let toast: ToastKitConfiguration let reduceMotion: Bool @State private var drag: CGSize = .zero + @State private var dynamicExpanded = false + @State private var dynamicDropExtended = false var body: some View { Group { - if toast.nativeIos { nativeContent } else { customContent } + if toast.presentation == .dynamic { + dynamicContent + } else if toast.nativeIos { + nativeContent + } else { + customContent + } } .offset(drag) .opacity(Double(max(CGFloat(0.35), CGFloat(1) - (abs(drag.width) + abs(drag.height)) / CGFloat(700)))) @@ -112,6 +135,99 @@ private struct ToastKitView: View { .toastKitConditional(toast.swipeToDismiss) { $0.gesture(dragGesture) } .accessibilityElement(children: .contain) .animation(reduceMotion ? .linear(duration: 0.1) : .spring(response: 0.3, dampingFraction: 0.78), value: drag) + .task(id: toast.presentation) { + guard toast.presentation == .dynamic else { + dynamicExpanded = false + dynamicDropExtended = false + return + } + withAnimation(dynamicAnimation) { dynamicDropExtended = true } + if !reduceMotion { try? await Task.sleep(for: .milliseconds(70)) } + withAnimation(dynamicAnimation) { dynamicExpanded = true } + } + } + + private var dynamicContent: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + if toast.progress == nil && toast.loading { + ProgressView() + .tint(toast.style.iconColor) + .controlSize(.small) + .frame(width: 24, height: 24) + } else { + let icon = toast.icon + Image(systemName: icon?.ios ?? getIconForName(icon?.name ?? "bell.fill")) + .font(.system(size: toastKitIconSize(toast.iconSize, fallback: 20), weight: .semibold)) + .foregroundStyle(toast.style.iconColor) + .frame(width: 24, height: 24) + .accessibilityHidden(true) + } + + if dynamicExpanded { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + if let title = toast.title { + Text(title) + .font(toastKitFont(toast.titleText, defaultSize: 15, defaultWeight: .semibold)) + .lineLimit(1) + .contentTransition(.opacity) + .animation(contentUpdateAnimation, value: title) + } + Text(toast.message) + .font(toastKitFont(toast.text, defaultSize: 14, defaultWeight: .medium)) + .lineLimit(2) + .contentTransition(.opacity) + .animation(contentUpdateAnimation, value: toast.message) + } + .foregroundStyle(toast.style.foreground) + .frame(minWidth: 120, maxWidth: 300, alignment: .leading) + + if let action = toast.action { + Button(action.label) { + ToastKitManager.shared.pressAction(toastId: toast.id, actionId: action.id) + } + .buttonStyle(.plain) + .font(.subheadline.weight(.bold)) + .foregroundStyle(toast.style.actionColor) + } + if toast.dismissible { + Button { ToastKitManager.shared.dismiss(toast.id) } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .foregroundStyle(toast.style.foreground) + .accessibilityLabel("Dismiss") + } + } + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + .padding(toast.style.padding) + + if dynamicExpanded, let progress = toast.progress { + ProgressView(value: progress, total: 100) + .tint(toast.style.actionColor) + .animation(reduceMotion ? .linear(duration: 0.1) : .easeInOut(duration: 0.2), value: progress) + } + } + .background( + toast.style.background, + in: RoundedRectangle(cornerRadius: toast.style.cornerRadius, style: .continuous) + ) + .clipShape(RoundedRectangle(cornerRadius: toast.style.cornerRadius, style: .continuous)) + .shadow(color: toast.style.shadow ? .black.opacity(0.28) : .clear, radius: 12, y: 5) + .overlay(alignment: .top) { + ToastKitDropShape() + .fill(toast.style.background) + .frame(width: 20, height: 26) + .scaleEffect(x: 1, y: dynamicDropExtended ? 1 : 0, anchor: .top) + .offset(y: -20) + .allowsHitTesting(false) + } + .animation(dynamicAnimation, value: dynamicExpanded) } private var customContent: some View { @@ -221,15 +337,15 @@ private struct ToastKitView: View { private var dragGesture: some Gesture { DragGesture(minimumDistance: 8) .onChanged { value in - switch toast.position { + switch effectivePosition(for: toast) { case .top: drag.height = min(0, value.translation.height) case .bottom: drag.height = max(0, value.translation.height) case .center: drag.width = value.translation.width } } .onEnded { value in - let projected = toast.position == .center ? value.predictedEndTranslation.width : value.predictedEndTranslation.height - let distance = toast.position == .center ? abs(value.translation.width) : abs(value.translation.height) + let projected = effectivePosition(for: toast) == .center ? value.predictedEndTranslation.width : value.predictedEndTranslation.height + let distance = effectivePosition(for: toast) == .center ? abs(value.translation.width) : abs(value.translation.height) if distance > 80 || abs(projected) > 180 { ToastKitManager.shared.dismiss(toast.id, reason: "swipe") } else { drag = .zero } } @@ -238,6 +354,39 @@ private struct ToastKitView: View { private var contentUpdateAnimation: Animation { reduceMotion ? .linear(duration: 0.01) : .easeInOut(duration: 0.16) } + + private var dynamicAnimation: Animation { + reduceMotion ? .linear(duration: 0.01) : .spring(response: 0.34, dampingFraction: 0.8) + } +} + +private struct ToastKitDropShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.width * 0.36, y: 0)) + path.addCurve( + to: CGPoint(x: rect.width * 0.15, y: rect.height * 0.66), + control1: CGPoint(x: rect.width * 0.38, y: rect.height * 0.38), + control2: CGPoint(x: rect.width * 0.30, y: rect.height * 0.48) + ) + path.addCurve( + to: CGPoint(x: rect.width * 0.50, y: rect.height), + control1: CGPoint(x: 0, y: rect.height * 0.84), + control2: CGPoint(x: rect.width * 0.18, y: rect.height) + ) + path.addCurve( + to: CGPoint(x: rect.width * 0.85, y: rect.height * 0.66), + control1: CGPoint(x: rect.width * 0.82, y: rect.height), + control2: CGPoint(x: rect.width, y: rect.height * 0.84) + ) + path.addCurve( + to: CGPoint(x: rect.width * 0.64, y: 0), + control1: CGPoint(x: rect.width * 0.70, y: rect.height * 0.48), + control2: CGPoint(x: rect.width * 0.62, y: rect.height * 0.38) + ) + path.closeSubpath() + return path + } } private struct ToastKitFramePreferenceKey: PreferenceKey { @@ -248,6 +397,10 @@ private struct ToastKitFramePreferenceKey: PreferenceKey { } } +private func effectivePosition(for toast: ToastKitConfiguration) -> ToastKitPosition { + toast.presentation == .dynamic ? .top : toast.position +} + private extension View { @ViewBuilder func toastKitConfiguredOpacity(_ opacity: Double?) -> some View { diff --git a/resources/js/PendingToast.js b/resources/js/PendingToast.js index c1976da..4d1a072 100644 --- a/resources/js/PendingToast.js +++ b/resources/js/PendingToast.js @@ -62,6 +62,7 @@ export class PendingToast extends PendingToastBase { title: null, variant: "neutral", position: "bottom", + presentation: "standard", native: { ios: false, android: false }, duration: 3000, persistent: false, diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index e201d74..c5202e7 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -58,6 +58,12 @@ export class PendingToastBase { position(value) { return this.set("position", oneOf(value, "position")); } + presentation(value) { + return this.set("presentation", oneOf(value, "presentation")); + } + dynamic() { + return this.presentation("dynamic"); + } native({ ios = true, android = true } = {}) { return this.set("native", { ios: Boolean(ios), android: Boolean(android) }); } diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index 7eb5bd3..f8780e6 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -93,6 +93,16 @@ test("position, duration, persistent and animation are set", () => { assert.equal(Toast.make("x").persistent().payload().duration, null); }); +test("presentation defaults to standard and supports dynamic updates", () => { + assert.equal(Toast.make("x").payload().presentation, "standard"); + assert.equal(Toast.make("x").presentation("dynamic").payload().presentation, "dynamic"); + assert.equal(Toast.make("x").dynamic().payload().presentation, "dynamic"); + assert.deepEqual(Toast.update("one").dynamic().payload().changes, { + presentation: "dynamic", + }); + assert.throws(() => Toast.make("x").presentation("floating"), /presentation/); +}); + test("native appearance is custom by default and selectable per platform", () => { assert.deepEqual(Toast.success("Default").payload().native, { ios: false, android: false }); assert.deepEqual(Toast.success("Both").native().payload().native, { ios: true, android: true }); diff --git a/resources/js/validation.js b/resources/js/validation.js index 562f769..b4529ad 100644 --- a/resources/js/validation.js +++ b/resources/js/validation.js @@ -1,6 +1,7 @@ export const allowed = { variant: ["neutral", "success", "error", "warning", "info"], position: ["top", "center", "bottom"], + presentation: ["standard", "dynamic"], animation: ["fade", "slide", "scale", "spring", "snap", "pop", "reveal", "bounce"], direction: ["auto", "left", "right", "top", "bottom"], strategy: ["queue", "stack"], diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 535ebc7..8902f9a 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -6,6 +6,7 @@ use Victorycodedev\ToastKit\Enums\ToastAnimation; use Victorycodedev\ToastKit\Enums\ToastDirection; use Victorycodedev\ToastKit\Enums\ToastPosition; +use Victorycodedev\ToastKit\Enums\ToastPresentation; use Victorycodedev\ToastKit\Enums\ToastStrategy; use Victorycodedev\ToastKit\Enums\ToastTextAlign; use Victorycodedev\ToastKit\Enums\ToastTextSize; @@ -101,6 +102,16 @@ public function position(ToastPosition|string $position): static return $this->put('position', $this->enumValue(ToastPosition::class, $position, 'position')); } + public function presentation(ToastPresentation|string $presentation): static + { + return $this->put('presentation', $this->enumValue(ToastPresentation::class, $presentation, 'presentation')); + } + + public function dynamic(): static + { + return $this->presentation(ToastPresentation::Dynamic); + } + public function native(bool $ios = true, bool $android = true): static { return $this->put('native', ['ios' => $ios, 'android' => $android]); diff --git a/src/Enums/ToastPresentation.php b/src/Enums/ToastPresentation.php new file mode 100644 index 0000000..e1432de --- /dev/null +++ b/src/Enums/ToastPresentation.php @@ -0,0 +1,9 @@ + null, 'variant' => 'neutral', 'position' => 'bottom', + 'presentation' => 'standard', 'native' => ['ios' => false, 'android' => false], 'duration' => 3000, 'persistent' => false, diff --git a/tests/Unit/BridgeTest.php b/tests/Unit/BridgeTest.php index 3221683..44dde29 100644 --- a/tests/Unit/BridgeTest.php +++ b/tests/Unit/BridgeTest.php @@ -13,6 +13,7 @@ 'title' => null, 'variant' => 'neutral', 'position' => 'bottom', + 'presentation' => 'standard', 'duration' => 3000, 'persistent' => false, 'animation' => 'scale', diff --git a/tests/Unit/PresentationTest.php b/tests/Unit/PresentationTest.php new file mode 100644 index 0000000..43e6e5e --- /dev/null +++ b/tests/Unit/PresentationTest.php @@ -0,0 +1,40 @@ +kit->make('Saved')->payload()['presentation'])->toBe('standard'); +}); + +it('selects a presentation by string or enum', function (ToastPresentation|string $presentation, string $expected) { + expect($this->kit->make('Saved')->presentation($presentation)->payload()['presentation'])->toBe($expected); +})->with([ + ['standard', 'standard'], + ['dynamic', 'dynamic'], + [ToastPresentation::Standard, 'standard'], + [ToastPresentation::Dynamic, 'dynamic'], +]); + +it('provides a dynamic presentation shortcut', function () { + expect($this->kit->make('Saved')->dynamic()->payload()['presentation'])->toBe('dynamic'); +}); + +it('rejects an invalid presentation', function () { + expect(fn () => $this->kit->make('Saved')->presentation('floating')) + ->toThrow(InvalidToastConfigurationException::class); +}); + +it('sends presentation as a sparse update', function () { + $this->kit->update('upload')->dynamic()->show(); + + expect($this->calls[0][1]['changes'])->toBe(['presentation' => 'dynamic']); +}); + +it('supports presentation in presets without leaking state', function () { + $this->kit->definePreset('dynamic-status', fn (PendingToast $toast) => $toast->dynamic()); + + expect($this->kit->preset('dynamic-status')->message('First')->payload()['presentation'])->toBe('dynamic') + ->and($this->kit->make('Second')->payload()['presentation'])->toBe('standard'); +}); From a8270c539687e1766914d10ba3206361e83c1e95 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Wed, 21 Oct 2026 16:00:39 +0100 Subject: [PATCH 3/6] fix: refine dynamic toast drop animation --- README.md | 2 +- .../plugins/toastkit/presentation/ToastKitHost.kt | 9 ++++++++- resources/ios/Sources/Presentation/ToastKitHost.swift | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 25d58f9..634a938 100644 --- a/README.md +++ b/README.md @@ -404,7 +404,7 @@ On iOS 26 and later, ToastKit uses Apple's system Liquid Glass effect. Earlier s ## Dynamic Presentation -Dynamic presentation flows from the top camera area with a short liquid-drop connector, starts as a compact foreground pill, and smoothly expands to reveal the toast content. It retracts toward the top when dismissed. The effect has a native SwiftUI design on iOS and a Material-style Compose design on Android: +Dynamic presentation briefly flows from the top camera area with a liquid-drop connector, starts as a compact foreground pill, and smoothly expands to reveal the toast content. The connector retracts once expansion finishes instead of remaining above the toast. The effect has a native SwiftUI design on iOS and a Material-style Compose design on Android: ```php Toast::info('Syncing contacts...') 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 29f26f5..0ec9de4 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -228,9 +228,16 @@ private fun DynamicToastKitCard( var dropExtended by remember(toast.id) { mutableStateOf(false) } LaunchedEffect(visible) { if (visible) { + if (reduceMotion) { + dropExtended = false + expanded = true + return@LaunchedEffect + } dropExtended = true - if (!reduceMotion) delay(70) + delay(70) expanded = true + delay(180) + dropExtended = false } else { expanded = false if (!reduceMotion) delay(45) diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index aaf7d79..500742c 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -90,7 +90,7 @@ private struct ToastKitDynamicPillModifier: ViewModifier { let amount: CGFloat func body(content: Content) -> some View { - content.scaleEffect(x: amount, y: 1).clipped() + content.scaleEffect(x: amount, y: 1) } } @@ -144,6 +144,8 @@ private struct ToastKitView: View { withAnimation(dynamicAnimation) { dynamicDropExtended = true } if !reduceMotion { try? await Task.sleep(for: .milliseconds(70)) } withAnimation(dynamicAnimation) { dynamicExpanded = true } + if !reduceMotion { try? await Task.sleep(for: .milliseconds(180)) } + withAnimation(dynamicAnimation) { dynamicDropExtended = false } } } From 0aebab7184a670ec1f7c70cf8b51fbdb2a0c8914 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Wed, 21 Oct 2026 20:11:36 +0100 Subject: [PATCH 4/6] feat: add toast press events and attached data --- README.md | 42 +++++++++++-- nativephp.json | 5 +- .../bridge/ToastKitBridgeNormalizer.kt | 14 +++++ .../bridge/ToastKitEventDispatcher.kt | 4 +- .../toastkit/manager/ToastKitManager.kt | 8 ++- .../plugins/toastkit/model/ToastKitModels.kt | 1 + .../toastkit/presentation/ToastKitHost.kt | 6 ++ .../Bridge/ToastKitBridgeNormalizer.swift | 16 +++++ .../Bridge/ToastKitEventDispatcher.swift | 4 +- .../ios/Sources/Manager/ToastKitManager.swift | 16 ++++- .../ios/Sources/Model/ToastKitModels.swift | 1 + .../Sources/Presentation/ToastKitHost.swift | 7 +++ resources/js/PendingToastBase.js | 14 +++++ resources/js/tests/toastkit.test.js | 17 ++++++ src/Concerns/ConfiguresToast.php | 11 ++++ src/Events/ToastActionPressed.php | 8 ++- src/Events/ToastPressed.php | 16 +++++ tests/Unit/AttachedDataTest.php | 59 +++++++++++++++++++ 18 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 src/Events/ToastPressed.php create mode 100644 tests/Unit/AttachedDataTest.php diff --git a/README.md b/README.md index 634a938..3150f5e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - **Polished updates** — title and message changes crossfade in place instead of snapping. - **Unique toasts** — suppress duplicate work by a stable semantic key across visible, queued, stacked, and persistent toasts. - **Idempotent dismissal** — dismiss by ID or dismiss everything. -- **Events** — `ToastShown`, `ToastDismissed`, and `ToastActionPressed`. +- **Events** — `ToastShown`, `ToastDismissed`, `ToastPressed`, and `ToastActionPressed`. - **JavaScript API** — a fluent `Toast` API for web-facing apps. ## Installation @@ -670,13 +670,41 @@ Dismissals are idempotent — dismissing an ID that is already gone is a no-op. ## Actions & Events -Add a native action button and handle its press: +Attach small routing or context values with `data()`, then handle a press anywhere on the toast body: + +```php +use Native\Mobile\Attributes\On; +use Victorycodedev\ToastKit\Events\ToastPressed; + +Toast::info('New message from Grace') + ->data([ + 'conversation_id' => 42, + 'message_id' => 781, + ]) + ->dynamic() + ->show(); + +#[On(ToastPressed::class)] +public function openConversation(string $toastId, array $data): void +{ + Toast::dismiss($toastId); + + $this->redirectRoute('conversations.show', [ + 'conversation' => $data['conversation_id'], + ]); +} +``` + +Whole-toast pressing is enabled only when non-empty `data()` is attached. Toasts without attached data keep their existing interaction behavior and do not emit `ToastPressed`; calling `data([])` disables whole-toast pressing again. A body press does not automatically dismiss the toast, so the listener decides whether to dismiss, update, or retain it. Action and close controls consume their own presses and do not also emit `ToastPressed`. + +Add a native action button and handle its press independently: ```php use Native\Mobile\Attributes\On; use Victorycodedev\ToastKit\Events\ToastActionPressed; Toast::error('Connection lost') + ->data(['request_id' => 91]) ->action( label: 'Retry', id: 'retry', @@ -687,6 +715,7 @@ Toast::error('Connection lost') public function handleToastAction( string $toastId, string $actionId, + array $data, ): void { if ($actionId === 'retry') { $this->retry(); @@ -694,7 +723,7 @@ public function handleToastAction( } ``` -Pressing an action emits `ToastActionPressed` and dismisses the toast. +Pressing an action emits `ToastActionPressed` with the attached data and dismisses the toast. Keep attached data small and JSON-serializable—IDs, types, and slugs are preferable to complete models or sensitive values. Calling `data()` during an update replaces the attached object; `data([])` clears it. The other events follow the same pattern: @@ -950,13 +979,14 @@ Refer to your Fetch package's documentation for its exact API surface. ## Events -ToastKit dispatches three events. Listen with NativePHP's `#[On]` attribute: +ToastKit dispatches four events. Listen with NativePHP's `#[On]` attribute: | Event | Payload | | --- | --- | | `Victorycodedev\ToastKit\Events\ToastShown` | `toastId` (string) | | `Victorycodedev\ToastKit\Events\ToastDismissed` | `toastId` (string), `reason` (string) | -| `Victorycodedev\ToastKit\Events\ToastActionPressed` | `toastId` (string), `actionId` (string) | +| `Victorycodedev\ToastKit\Events\ToastPressed` | `toastId` (string), `data` (array) | +| `Victorycodedev\ToastKit\Events\ToastActionPressed` | `toastId` (string), `actionId` (string), `data` (array) | ```php use Native\Mobile\Attributes\On; @@ -1070,6 +1100,7 @@ Native::test(ProfileScreen::class) | `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. | +| `data(array $data)` | Attach JSON-serializable context delivered with body-press and action events. | | `background(string $color)` | Set the background color. | | `foreground(string $color)` | Set the text color. | | `iconColor(string $color)` | Set the icon color. | @@ -1117,6 +1148,7 @@ Native::test(ProfileScreen::class) | `icon_size` | Platform default (omitted) | | `swipe_to_dismiss` | `true` | | `dismissible` | `false` | +| `data` | Empty and omitted unless configured | | `strategy` | `queue` | | `max_visible` | `3` | | `corner_radius` | `16` | diff --git a/nativephp.json b/nativephp.json index 237441d..b116099 100644 --- a/nativephp.json +++ b/nativephp.json @@ -74,7 +74,8 @@ "events": [ "Victorycodedev\\ToastKit\\Events\\ToastShown", "Victorycodedev\\ToastKit\\Events\\ToastDismissed", - "Victorycodedev\\ToastKit\\Events\\ToastActionPressed" + "Victorycodedev\\ToastKit\\Events\\ToastActionPressed", + "Victorycodedev\\ToastKit\\Events\\ToastPressed" ], "service_provider": "Victorycodedev\\ToastKit\\ToastKitServiceProvider" -} \ No newline at end of file +} 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 54ffe1a..28b60fb 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -2,6 +2,7 @@ package com.victorycodedev.plugins.toastkit.bridge import androidx.compose.ui.graphics.Color import com.victorycodedev.plugins.toastkit.model.* +import org.json.JSONArray import org.json.JSONObject internal class ToastKitInputException(message: String) : IllegalArgumentException(message) @@ -54,6 +55,7 @@ internal object ToastKitBridgeNormalizer { 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)) + if (changes.containsKey("data")) next = next.copy(data = data(changes["data"] ?: emptyMap())) changes["strategy"]?.let { next = next.copy(strategy = strategy(it)) } changes["max_visible"]?.let { next = next.copy(maxVisible = positiveInt(it, "max_visible")) } changes["style"]?.let { next = next.copy(style = style(it, next.style)) } @@ -93,6 +95,7 @@ internal object ToastKitBridgeNormalizer { 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), + data = values["data"]?.let(::data) ?: emptyMap(), style = style(values["style"] ?: emptyMap(), variantStyle(values["variant"] as? String ?: "neutral")), strategy = strategy(values["strategy"] ?: "queue"), maxVisible = positiveInt(values["max_visible"] ?: 3, "max_visible"), @@ -118,6 +121,17 @@ internal object ToastKitBridgeNormalizer { return ToastKitAction(requiredString(map, "id"), requiredString(map, "label")) } + private fun data(value: Any): Map { + stringMap(value)?.let { return it } + + // PHP encodes an empty associative array as JSON `[]`. Treat that + // representation as an empty data object so data remains optional. + if (value is JSONArray && value.length() == 0) return emptyMap() + if (value is Collection<*> && value.isEmpty()) return emptyMap() + + throw ToastKitInputException("data must be an object") + } + private fun nativeMode(value: Any): Pair { val map = stringMap(value) ?: throw ToastKitInputException("native must be an object") return (map["ios"]?.let { boolean(it, "native.ios") } ?: false) to diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitEventDispatcher.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitEventDispatcher.kt index 71cf33c..ecad958 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitEventDispatcher.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitEventDispatcher.kt @@ -9,12 +9,14 @@ internal object ToastKitEventDispatcher { private const val SHOWN = "Victorycodedev\\ToastKit\\Events\\ToastShown" private const val DISMISSED = "Victorycodedev\\ToastKit\\Events\\ToastDismissed" private const val ACTION = "Victorycodedev\\ToastKit\\Events\\ToastActionPressed" + private const val PRESSED = "Victorycodedev\\ToastKit\\Events\\ToastPressed" private var activity = WeakReference(null) fun bind(value: FragmentActivity) { activity = WeakReference(value) } fun shown(id: String) = send(SHOWN, JSONObject().put("toastId", id)) fun dismissed(id: String, reason: String) = send(DISMISSED, JSONObject().put("toastId", id).put("reason", reason)) - fun action(id: String, actionId: String) = send(ACTION, JSONObject().put("toastId", id).put("actionId", actionId)) + fun action(id: String, actionId: String, data: Map) = send(ACTION, JSONObject().put("toastId", id).put("actionId", actionId).put("data", JSONObject(data))) + fun pressed(id: String, data: Map) = send(PRESSED, JSONObject().put("toastId", id).put("data", JSONObject(data))) private fun send(event: String, payload: JSONObject) { activity.get()?.let { NativeActionCoordinator.dispatchEvent(it, event, payload.toString()) } 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 f33dc7c..74792ef 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt @@ -72,10 +72,16 @@ internal object ToastKitManager { fun action(id: String, actionId: String) = onMain { val state = states[id] ?: return@onMain if (state.terminated) return@onMain - ToastKitEventDispatcher.action(id, actionId) + ToastKitEventDispatcher.action(id, actionId, state.configuration.data) terminate(id, "action") } + fun press(id: String) = onMain { + val state = states[id] ?: return@onMain + if (state.terminated || state.configuration.data.isEmpty()) return@onMain + ToastKitEventDispatcher.pressed(id, state.configuration.data) + } + private fun canAdmit(configuration: ToastKitConfiguration): Boolean { if (exiting.isNotEmpty()) return false val current = visible.mapNotNull { states[it.id]?.configuration } 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 95d59ac..db5be06 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -52,6 +52,7 @@ internal data class ToastKitConfiguration( val swipeToDismiss: Boolean, val dismissible: Boolean, val action: ToastKitAction?, + val data: Map, val style: ToastKitStyle, val strategy: ToastKitStrategy, val maxVisible: Int, 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 0ec9de4..f80ee25 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -137,6 +138,11 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp) .then(if (toast.presentation == ToastKitPresentation.STANDARD) Modifier.fillMaxWidth() else Modifier) .semantics { liveRegion = LiveRegionMode.Polite } + .then( + if (toast.data.isNotEmpty()) Modifier.clickable(onClickLabel = "Open notification") { + ToastKitManager.press(toast.id) + } else Modifier, + ) .graphicsLayer { translationX = displayedX; translationY = displayedY; alpha = (1f - (abs(displayedX) + abs(displayedY)) / 700f).coerceIn(.35f, 1f) } .then(toast.opacity?.let { Modifier.alpha(it) } ?: Modifier) .then(gesture) diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index fe82655..b0872bb 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -58,6 +58,7 @@ enum ToastKitBridgeNormalizer { 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) } + if changes.keys.contains("data") { next.data = try data(changes["data"] ?? [String: Any]()) } if let value = changes["strategy"] { next.strategy = try enumValue(value, "strategy") } if let value = changes["max_visible"] { next.maxVisible = try positiveInt(value, "max_visible") } if let value = changes["style"] { next.style = try style(value, fallback: next.style) } @@ -95,6 +96,7 @@ enum ToastKitBridgeNormalizer { 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), + data: try values["data"].map(data) ?? [:], style: try style(values["style"] ?? [String: Any](), fallback: variantStyle(variantName)), strategy: try enumValue(values["strategy"] ?? "queue", "strategy"), maxVisible: try positiveInt(values["max_visible"] ?? 3, "max_visible"), @@ -116,6 +118,20 @@ enum ToastKitBridgeNormalizer { return ToastKitActionConfiguration(id: try requiredString(map, "id"), label: try requiredString(map, "label")) } + private static func data(_ value: Any) throws -> [String: Any] { + if let data = value as? [String: Any] { + return data + } + + // PHP encodes an empty associative array as JSON `[]`. Treat that + // representation as an empty data object so data remains optional. + if let data = value as? [Any], data.isEmpty { + return [:] + } + + throw ToastKitInputError.invalid("data must be an object") + } + private static func nativeMode(_ value: Any) throws -> (ios: Bool, android: Bool) { guard let map = value as? [String: Any] else { throw ToastKitInputError.invalid("native must be an object") } return ( diff --git a/resources/ios/Sources/Bridge/ToastKitEventDispatcher.swift b/resources/ios/Sources/Bridge/ToastKitEventDispatcher.swift index f9d19ee..e4bb0b2 100644 --- a/resources/ios/Sources/Bridge/ToastKitEventDispatcher.swift +++ b/resources/ios/Sources/Bridge/ToastKitEventDispatcher.swift @@ -4,10 +4,12 @@ enum ToastKitEventDispatcher { private static let shownEvent = "Victorycodedev\\ToastKit\\Events\\ToastShown" private static let dismissedEvent = "Victorycodedev\\ToastKit\\Events\\ToastDismissed" private static let actionEvent = "Victorycodedev\\ToastKit\\Events\\ToastActionPressed" + private static let pressedEvent = "Victorycodedev\\ToastKit\\Events\\ToastPressed" static func shown(_ id: String) { send(shownEvent, ["toastId": id]) } static func dismissed(_ id: String, reason: String) { send(dismissedEvent, ["toastId": id, "reason": reason]) } - static func action(_ id: String, actionId: String) { send(actionEvent, ["toastId": id, "actionId": actionId]) } + static func action(_ id: String, actionId: String, data: [String: Any]) { send(actionEvent, ["toastId": id, "actionId": actionId, "data": data]) } + static func pressed(_ id: String, data: [String: Any]) { send(pressedEvent, ["toastId": id, "data": data]) } private static func send(_ event: String, _ payload: [String: Any?]) { LaravelBridge.shared.send?(event, payload) diff --git a/resources/ios/Sources/Manager/ToastKitManager.swift b/resources/ios/Sources/Manager/ToastKitManager.swift index 71d4e46..07e0dd1 100644 --- a/resources/ios/Sources/Manager/ToastKitManager.swift +++ b/resources/ios/Sources/Manager/ToastKitManager.swift @@ -39,10 +39,24 @@ final class ToastKitManager: ObservableObject { func pressAction(toastId: String, actionId: String) { guard let state = states[toastId], !state.terminated else { return } - ToastKitEventDispatcher.action(toastId, actionId: actionId) + ToastKitEventDispatcher.action(toastId, actionId: actionId, data: state.configuration.data) terminate(toastId, reason: "action") } + func press(_ id: String) { + // Defer body presses by one main-actor turn. If the same touch belongs + // to an action or close button, that control terminates the toast first + // and prevents an additional ToastPressed event. + Task { @MainActor [weak self] in + await Task.yield() + guard let self, + let state = self.states[id], + !state.terminated, + !state.configuration.data.isEmpty else { return } + ToastKitEventDispatcher.pressed(id, data: state.configuration.data) + } + } + func updateInteractiveFrames(_ frames: [String: CGRect]) { interactiveFrames = frames } diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index bb8bd1e..9d89c58 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -59,6 +59,7 @@ struct ToastKitConfiguration: Identifiable { var swipeToDismiss: Bool var dismissible: Bool var action: ToastKitActionConfiguration? + var data: [String: Any] var style: ToastKitStyleConfiguration var strategy: ToastKitStrategy var maxVisible: Int diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index 500742c..d86c83a 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -124,12 +124,19 @@ private struct ToastKitView: View { .opacity(Double(max(CGFloat(0.35), CGFloat(1) - (abs(drag.width) + abs(drag.height)) / CGFloat(700)))) .toastKitConfiguredOpacity(toast.opacity) .contentShape(Rectangle()) + .toastKitConditional(!toast.data.isEmpty) { + $0.onTapGesture { ToastKitManager.shared.press(toast.id) } + .accessibilityAction(named: Text("Open notification")) { + ToastKitManager.shared.press(toast.id) + } + } .background { GeometryReader { proxy in Color.clear.preference( key: ToastKitFramePreferenceKey.self, value: [toast.id: proxy.frame(in: .global)] ) + .allowsHitTesting(false) } } .toastKitConditional(toast.swipeToDismiss) { $0.gesture(dragGesture) } diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index c5202e7..f6eba03 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -107,6 +107,20 @@ export class PendingToastBase { label: nonEmpty(label, "action label"), }); } + data(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new TypeError("toast data must be an object"); + try { + const encoded = JSON.stringify(value, (_key, item) => { + if (["bigint", "function", "symbol"].includes(typeof item)) + throw new TypeError("unsupported toast data value"); + return item; + }); + return this.set("data", JSON.parse(encoded)); + } catch { + throw new TypeError("toast data must be serializable"); + } + } typography(group, options) { const values = {}; if (options.font !== undefined) diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index f8780e6..1f257e0 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -173,6 +173,23 @@ test("swipe, dismissible and action are set", () => { assert.deepEqual(payload.action, { id: "retry", label: "Retry" }); }); +test("attached data is copied and remains sparse in updates", () => { + const data = { conversation_id: 42, message: { id: 781 } }; + const pending = Toast.info("New message").data(data); + data.message.id = 999; + + assert.equal("data" in Toast.info("Existing toast").payload(), false); + assert.deepEqual(pending.payload().data, { + conversation_id: 42, + message: { id: 781 }, + }); + assert.deepEqual(Toast.update("one").data({ message_id: 781 }).payload().changes, { + data: { message_id: 781 }, + }); + assert.throws(() => Toast.make("x").data([]), /data must be an object/); + assert.throws(() => Toast.make("x").data({ value: 1n }), /serializable/); +}); + test("custom styling is normalized", () => { const payload = Toast.make("x") .background("#abc") diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 8902f9a..eb0c4cd 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -171,6 +171,17 @@ public function action(string $label, string $id): static return $this->put('action', ['id' => $id, 'label' => $label]); } + public function data(array $data): static + { + try { + json_encode($data, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new InvalidToastConfigurationException('Toast data must be JSON serializable.'); + } + + return $this->put('data', $data); + } + public function background(string $color): static { return $this->putStyle('background', $this->color($color)); diff --git a/src/Events/ToastActionPressed.php b/src/Events/ToastActionPressed.php index bb6ab7e..99d64d1 100644 --- a/src/Events/ToastActionPressed.php +++ b/src/Events/ToastActionPressed.php @@ -9,5 +9,9 @@ class ToastActionPressed { use Dispatchable, SerializesModels; - public function __construct(public string $toastId, public string $actionId) {} -} \ No newline at end of file + public function __construct( + public string $toastId, + public string $actionId, + public array $data = [], + ) {} +} diff --git a/src/Events/ToastPressed.php b/src/Events/ToastPressed.php new file mode 100644 index 0000000..fe67c17 --- /dev/null +++ b/src/Events/ToastPressed.php @@ -0,0 +1,16 @@ +kit->info('Existing toast')->payload())->not->toHaveKey('data'); +}); + +it('attaches structured data to a toast', function () { + $payload = $this->kit->info('New message')->data([ + 'conversation_id' => 42, + 'message' => ['id' => 781, 'unread' => true], + ])->payload(); + + expect($payload['data'])->toBe([ + 'conversation_id' => 42, + 'message' => ['id' => 781, 'unread' => true], + ]); +}); + +it('replaces attached data through a sparse update', function () { + $this->kit->update('message-toast')->data(['conversation_id' => 43])->show(); + + expect($this->calls[0][1]['changes'])->toBe([ + 'data' => ['conversation_id' => 43], + ]); +}); + +it('allows attached data to be cleared', function () { + expect($this->kit->make('Saved')->data([])->payload()['data'])->toBe([]); +}); + +it('rejects data that cannot be encoded as JSON', function () { + $resource = fopen('php://memory', 'r'); + + try { + expect(fn() => $this->kit->make('x')->data(['stream' => $resource])) + ->toThrow(InvalidToastConfigurationException::class); + } finally { + fclose($resource); + } +}); + +it('exposes attached data on press events', function () { + $event = new ToastPressed('toast-one', ['conversation_id' => 42]); + + expect($event->toastId)->toBe('toast-one') + ->and($event->data)->toBe(['conversation_id' => 42]); +}); + +it('adds data to action events without breaking the old constructor', function () { + $legacy = new ToastActionPressed('toast-one', 'retry'); + $event = new ToastActionPressed('toast-two', 'reply', ['message_id' => 781]); + + expect($legacy->data)->toBe([]) + ->and($event->data)->toBe(['message_id' => 781]); +}); From 2fe6015027dcdf1d78c9bbd18d34b9ba6e9b68a0 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Wed, 21 Oct 2026 20:22:45 +0100 Subject: [PATCH 5/6] fix: align dynamic toast actions on Android --- .../toastkit/presentation/ToastKitHost.kt | 83 ++++++++++--------- 1 file changed, 44 insertions(+), 39 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 f80ee25..df68edd 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -301,52 +301,57 @@ private fun DynamicToastKitCard( AnimatedVisibility( visible = expanded, + modifier = Modifier.weight(1f), enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Column(Modifier.widthIn(min = 120.dp, max = 320.dp).weight(1f, fill = false)) { - AnimatedContent( - targetState = toast.title, - transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, - label = "dynamic-toast-title-update", - ) { title -> - title?.let { Text( - title, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Column(Modifier.weight(1f)) { + AnimatedContent( + targetState = toast.title, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "dynamic-toast-title-update", + ) { title -> + title?.let { Text( + title, + color = toast.style.foreground, + fontSize = toast.titleText?.size?.let(::toastTextSize) ?: 16.sp, + fontWeight = toast.titleText?.weight?.let(::toastTextWeight) ?: FontWeight.SemiBold, + fontStyle = toast.titleText?.italic?.let { italic -> if (italic) FontStyle.Italic else FontStyle.Normal }, + fontFamily = toast.titleText?.font?.let(::toastFontFamily), + maxLines = 1, + ) } + } + AnimatedContent( + targetState = toast.message, + transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, + label = "dynamic-toast-message-update", + ) { message -> Text( + message, color = toast.style.foreground, - fontSize = toast.titleText?.size?.let(::toastTextSize) ?: 16.sp, - fontWeight = toast.titleText?.weight?.let(::toastTextWeight) ?: FontWeight.SemiBold, - fontStyle = toast.titleText?.italic?.let { italic -> if (italic) FontStyle.Italic else FontStyle.Normal }, - fontFamily = toast.titleText?.font?.let(::toastFontFamily), - maxLines = 1, + fontSize = toast.text?.size?.let(::toastTextSize) ?: toastTextSize(ToastKitTextSize.BASE), + fontWeight = toast.text?.weight?.let(::toastTextWeight) ?: FontWeight.Medium, + fontStyle = toast.text?.italic?.let { if (it) FontStyle.Italic else FontStyle.Normal }, + fontFamily = toast.text?.font?.let(::toastFontFamily), + maxLines = 2, ) } } - AnimatedContent( - targetState = toast.message, - transitionSpec = { fadeIn(tween(if (reduceMotion) 1 else 140)) togetherWith fadeOut(tween(if (reduceMotion) 1 else 100)) }, - label = "dynamic-toast-message-update", - ) { message -> Text( - message, - color = toast.style.foreground, - fontSize = toast.text?.size?.let(::toastTextSize) ?: toastTextSize(ToastKitTextSize.BASE), - fontWeight = toast.text?.weight?.let(::toastTextWeight) ?: FontWeight.Medium, - fontStyle = toast.text?.italic?.let { if (it) FontStyle.Italic else FontStyle.Normal }, - fontFamily = toast.text?.font?.let(::toastFontFamily), - maxLines = 2, - ) } - } - toast.action?.let { action -> - TextButton( - onClick = { ToastKitManager.action(toast.id, action.id) }, - colors = ButtonDefaults.textButtonColors(contentColor = toast.style.actionColor), - contentPadding = PaddingValues(horizontal = 8.dp), - ) { Text(action.label, fontWeight = FontWeight.Bold, maxLines = 1) } - } - if (toast.dismissible) { - IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { - MaterialIcon("close", "Dismiss", size = 20.dp, tint = toast.style.foreground) + toast.action?.let { action -> + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = toast.style.actionColor), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { Text(action.label, fontWeight = FontWeight.Bold, maxLines = 1) } + } + if (toast.dismissible) { + IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { + MaterialIcon("close", "Dismiss", size = 20.dp, tint = toast.style.foreground) + } } - } } } } From 1948d2a5fcee74a4db4fb24cc703cd955107533e Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Wed, 21 Oct 2026 20:40:37 +0100 Subject: [PATCH 6/6] feat: implement support for multiple toast actions with customizable colors --- README.md | 15 +++++++ .../bridge/ToastKitBridgeNormalizer.kt | 24 ++++++++-- .../plugins/toastkit/model/ToastKitModels.kt | 4 +- .../toastkit/presentation/ToastKitHost.kt | 12 ++--- .../Bridge/ToastKitBridgeNormalizer.swift | 22 ++++++++-- .../ios/Sources/Model/ToastKitModels.swift | 3 +- .../Sources/Presentation/ToastKitHost.swift | 11 ++--- resources/js/PendingToastBase.js | 24 ++++++++++ resources/js/tests/toastkit.test.js | 28 ++++++++++++ src/Concerns/ConfiguresToast.php | 39 ++++++++++++++++ src/PendingToast.php | 6 +++ src/PendingToastUpdate.php | 6 +++ tests/Unit/ConfigurationTest.php | 44 +++++++++++++++++++ tests/Unit/UpdateTest.php | 14 ++++++ 14 files changed, 232 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3150f5e..e41bfd6 100644 --- a/README.md +++ b/README.md @@ -725,6 +725,20 @@ public function handleToastAction( Pressing an action emits `ToastActionPressed` with the attached data and dismisses the toast. Keep attached data small and JSON-serializable—IDs, types, and slugs are preferable to complete models or sensitive values. Calling `data()` during an update replaces the attached object; `data([])` clears it. +Use `actions()` when a toast needs two choices. Every entry must explicitly provide both a non-empty `label` and a unique non-empty `id`: + +```php +Toast::warning('Delete this draft?') + ->data(['draft_id' => 42]) + ->actions([ + ['label' => 'Cancel', 'id' => 'cancel', 'color' => '#D1D5DB'], + ['label' => 'Delete', 'id' => 'delete', 'color' => '#EF4444'], + ]) + ->show(); +``` + +`actions()` accepts one or two actions. The optional `color` on an individual action overrides `actionColor()` for that button; actions without a color continue using the shared action color or platform default. Pressing either action emits `ToastActionPressed` with that action's ID and then automatically dismisses the toast, matching `action()`. If `action()` and `actions()` are called on the same builder, the last method called wins. This makes it possible for an individual toast to override actions inherited from a preset without sending conflicting bridge values. + The other events follow the same pattern: ```php @@ -1100,6 +1114,7 @@ Native::test(ProfileScreen::class) | `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. | +| `actions(array $actions)` | Add one or two actions; every entry requires a unique `id` and a `label`, with an optional per-action `color`. | | `data(array $data)` | Attach JSON-serializable context delivered with body-press and action events. | | `background(string $color)` | Set the background color. | | `foreground(string $color)` | Set the text color. | 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 28b60fb..4da0835 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -54,7 +54,8 @@ internal object ToastKitBridgeNormalizer { 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)) + if (changes.containsKey("action")) next = next.copy(actions = optionalValue(changes["action"])?.let(::action)?.let(::listOf) ?: emptyList()) + if (changes.containsKey("actions")) next = next.copy(actions = actions(changes["actions"] ?: emptyList())) if (changes.containsKey("data")) next = next.copy(data = data(changes["data"] ?: emptyMap())) changes["strategy"]?.let { next = next.copy(strategy = strategy(it)) } changes["max_visible"]?.let { next = next.copy(maxVisible = positiveInt(it, "max_visible")) } @@ -94,7 +95,8 @@ internal object ToastKitBridgeNormalizer { 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), + actions = if (values.containsKey("actions")) actions(values["actions"] ?: emptyList()) + else optionalValue(values["action"])?.let(::action)?.let(::listOf) ?: emptyList(), data = values["data"]?.let(::data) ?: emptyMap(), style = style(values["style"] ?: emptyMap(), variantStyle(values["variant"] as? String ?: "neutral")), strategy = strategy(values["strategy"] ?: "queue"), @@ -118,7 +120,23 @@ internal object ToastKitBridgeNormalizer { private fun action(value: Any): ToastKitAction { val map = stringMap(value) ?: throw ToastKitInputException("action must be an object") - return ToastKitAction(requiredString(map, "id"), requiredString(map, "label")) + return ToastKitAction( + requiredString(map, "id"), + requiredString(map, "label"), + map["color"]?.let { color(it, "action.color") }, + ) + } + + private fun actions(value: Any): List { + val values = when (value) { + is JSONArray -> (0 until value.length()).map(value::get) + is Collection<*> -> value.toList() + else -> throw ToastKitInputException("actions must be an array") + } + requireInput(values.size in 1..2, "actions must contain one or two actions") + val actions = values.map { action(it ?: throw ToastKitInputException("action must be an object")) } + requireInput(actions.map { it.id }.distinct().size == actions.size, "action IDs must be unique") + return actions } private fun data(value: Any): Map { 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 db5be06..00621e2 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -13,7 +13,7 @@ internal enum class ToastKitTextAlign { LEFT, CENTER, RIGHT } internal enum class ToastKitIconSize { SM, MD, LG } internal data class ToastKitIcon(val name: String?, val android: String?) -internal data class ToastKitAction(val id: String, val label: String) +internal data class ToastKitAction(val id: String, val label: String, val color: Color? = null) internal data class ToastKitTypography( val font: String? = null, val size: ToastKitTextSize? = null, @@ -51,7 +51,7 @@ internal data class ToastKitConfiguration( val loading: Boolean, val swipeToDismiss: Boolean, val dismissible: Boolean, - val action: ToastKitAction?, + val actions: List, val data: Map, val style: ToastKitStyle, val strategy: ToastKitStrategy, 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 df68edd..e18df95 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -195,10 +195,10 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { modifier = Modifier.fillMaxWidth(), ) } } - toast.action?.let { action -> + toast.actions.forEach { action -> TextButton( onClick = { ToastKitManager.action(toast.id, action.id) }, - colors = ButtonDefaults.textButtonColors(contentColor = toast.style.actionColor), + colors = ButtonDefaults.textButtonColors(contentColor = action.color ?: toast.style.actionColor), contentPadding = PaddingValues(horizontal = 8.dp), ) { Text(action.label, fontWeight = FontWeight.Bold) } } @@ -340,10 +340,10 @@ private fun DynamicToastKitCard( maxLines = 2, ) } } - toast.action?.let { action -> + toast.actions.forEach { action -> TextButton( onClick = { ToastKitManager.action(toast.id, action.id) }, - colors = ButtonDefaults.textButtonColors(contentColor = toast.style.actionColor), + colors = ButtonDefaults.textButtonColors(contentColor = action.color ?: toast.style.actionColor), contentPadding = PaddingValues(horizontal = 8.dp), ) { Text(action.label, fontWeight = FontWeight.Bold, maxLines = 1) } } @@ -401,10 +401,10 @@ private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: label = "native-toast-message-update", ) { Text(it, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium) } } - toast.action?.let { action -> + toast.actions.forEach { action -> TextButton( onClick = { ToastKitManager.action(toast.id, action.id) }, - colors = ButtonDefaults.textButtonColors(contentColor = SnackbarDefaults.actionColor), + colors = ButtonDefaults.textButtonColors(contentColor = action.color ?: SnackbarDefaults.actionColor), ) { Text(action.label) } } if (toast.dismissible) { diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index b0872bb..ba2383e 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -57,7 +57,8 @@ enum ToastKitBridgeNormalizer { 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) } + if changes.keys.contains("action") { next.actions = try changes["action"].map(action).map { [$0] } ?? [] } + if changes.keys.contains("actions") { next.actions = try actions(changes["actions"] ?? [Any]()) } if changes.keys.contains("data") { next.data = try data(changes["data"] ?? [String: Any]()) } if let value = changes["strategy"] { next.strategy = try enumValue(value, "strategy") } if let value = changes["max_visible"] { next.maxVisible = try positiveInt(value, "max_visible") } @@ -95,7 +96,7 @@ enum ToastKitBridgeNormalizer { 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), + actions: try values["actions"].map(actions) ?? values["action"].map(action).map { [$0] } ?? [], data: try values["data"].map(data) ?? [:], style: try style(values["style"] ?? [String: Any](), fallback: variantStyle(variantName)), strategy: try enumValue(values["strategy"] ?? "queue", "strategy"), @@ -115,7 +116,22 @@ enum ToastKitBridgeNormalizer { private static func action(_ value: Any) throws -> ToastKitActionConfiguration { guard let map = value as? [String: Any] else { throw ToastKitInputError.invalid("action must be an object") } - return ToastKitActionConfiguration(id: try requiredString(map, "id"), label: try requiredString(map, "label")) + return ToastKitActionConfiguration( + id: try requiredString(map, "id"), + label: try requiredString(map, "label"), + color: try map["color"].map { try color($0, "action.color") } + ) + } + + private static func actions(_ value: Any) throws -> [ToastKitActionConfiguration] { + guard let values = value as? [Any], (1...2).contains(values.count) else { + throw ToastKitInputError.invalid("actions must contain one or two actions") + } + let actions = try values.map(action) + guard Set(actions.map(\.id)).count == actions.count else { + throw ToastKitInputError.invalid("action IDs must be unique") + } + return actions } private static func data(_ value: Any) throws -> [String: Any] { diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index 9d89c58..c684974 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -18,6 +18,7 @@ struct ToastKitIconConfiguration { struct ToastKitActionConfiguration { let id: String let label: String + let color: Color? } struct ToastKitTypographyConfiguration { @@ -58,7 +59,7 @@ struct ToastKitConfiguration: Identifiable { var loading: Bool var swipeToDismiss: Bool var dismissible: Bool - var action: ToastKitActionConfiguration? + var actions: [ToastKitActionConfiguration] var data: [String: Any] var style: ToastKitStyleConfiguration var strategy: ToastKitStrategy diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index d86c83a..26c6409 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -192,13 +192,13 @@ private struct ToastKitView: View { .foregroundStyle(toast.style.foreground) .frame(minWidth: 120, maxWidth: 300, alignment: .leading) - if let action = toast.action { + ForEach(toast.actions, id: \.id) { action in Button(action.label) { ToastKitManager.shared.pressAction(toastId: toast.id, actionId: action.id) } .buttonStyle(.plain) .font(.subheadline.weight(.bold)) - .foregroundStyle(toast.style.actionColor) + .foregroundStyle(action.color ?? toast.style.actionColor) } if toast.dismissible { Button { ToastKitManager.shared.dismiss(toast.id) } label: { @@ -267,9 +267,9 @@ private struct ToastKitView: View { .animation(contentUpdateAnimation, value: toast.message) } .foregroundStyle(toast.style.foreground).frame(maxWidth: .infinity, alignment: .leading) - if let action = toast.action { + ForEach(toast.actions, id: \.id) { action in Button(action.label) { ToastKitManager.shared.pressAction(toastId: toast.id, actionId: action.id) } - .font(.subheadline.weight(.bold)).foregroundStyle(toast.style.actionColor) + .font(.subheadline.weight(.bold)).foregroundStyle(action.color ?? toast.style.actionColor) } if toast.dismissible { Button { ToastKitManager.shared.dismiss(toast.id) } label: { @@ -318,10 +318,11 @@ private struct ToastKitView: View { .animation(contentUpdateAnimation, value: toast.message) } .frame(maxWidth: .infinity, alignment: .leading) - if let action = toast.action { + ForEach(toast.actions, id: \.id) { action in Button(action.label) { ToastKitManager.shared.pressAction(toastId: toast.id, actionId: action.id) } .buttonStyle(.borderless) .font(.subheadline.weight(.semibold)) + .foregroundStyle(action.color ?? toast.style.actionColor) } if toast.dismissible { Button { ToastKitManager.shared.dismiss(toast.id) } label: { diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index f6eba03..963c87b 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -13,6 +13,10 @@ export class PendingToastBase { this.values[key] = value; return this; } + unset(key) { + delete this.values[key]; + return this; + } message(value) { return this.set("message", nonEmpty(value, "message")); } @@ -102,11 +106,31 @@ export class PendingToastBase { return this.set("dismissible", Boolean(enabled)); } action(label, id) { + this.unset("actions"); return this.set("action", { id: nonEmpty(id, "action ID"), label: nonEmpty(label, "action label"), }); } + actions(actions) { + if (!Array.isArray(actions) || actions.length < 1 || actions.length > 2) + throw new TypeError("toast actions must contain one or two actions"); + const normalized = actions.map((action) => { + if (action === null || typeof action !== "object" || Array.isArray(action)) + throw new TypeError("each toast action must specify a label and ID"); + const normalized = { + id: nonEmpty(action.id, "action ID"), + label: nonEmpty(action.label, "action label"), + }; + if (action.color !== undefined) + normalized.color = color(action.color, "action color"); + return normalized; + }); + if (new Set(normalized.map(({ id }) => id)).size !== normalized.length) + throw new TypeError("toast action IDs must be unique"); + this.unset("action"); + return this.set("actions", normalized); + } data(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("toast data must be an object"); diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index 1f257e0..ebd8810 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -173,6 +173,34 @@ test("swipe, dismissible and action are set", () => { assert.deepEqual(payload.action, { id: "retry", label: "Retry" }); }); +test("actions accepts at most two labeled actions and the last API wins", () => { + const actions = [ + { label: "Cancel", id: "cancel" }, + { label: "Delete", id: "delete", color: "#ef4444" }, + ]; + assert.deepEqual(Toast.make("x").actions(actions).payload().actions, [ + { id: "cancel", label: "Cancel" }, + { id: "delete", label: "Delete", color: "#EF4444" }, + ]); + + const single = Toast.make("x").actions(actions).action("Undo", "undo").payload(); + assert.deepEqual(single.action, { id: "undo", label: "Undo" }); + assert.equal("actions" in single, false); + + const multiple = Toast.make("x").action("Undo", "undo").actions(actions).payload(); + assert.equal("action" in multiple, false); + assert.deepEqual(multiple.actions, [ + { id: "cancel", label: "Cancel" }, + { id: "delete", label: "Delete", color: "#EF4444" }, + ]); + + assert.throws(() => Toast.make("x").actions([]), /one or two/); + assert.throws(() => Toast.make("x").actions([...actions, { label: "Save", id: "save" }]), /one or two/); + assert.throws(() => Toast.make("x").actions([{ label: "Missing ID" }]), /action ID/); + assert.throws(() => Toast.make("x").actions([{ label: "One", id: "same" }, { label: "Two", id: "same" }]), /unique/); + assert.throws(() => Toast.make("x").actions([{ label: "One", id: "one", color: "red" }]), /hexadecimal/); +}); + test("attached data is copied and remains sparse in updates", () => { const data = { conversation_id: 42, message: { id: 781 } }; const pending = Toast.info("New message").data(data); diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index eb0c4cd..4524bbf 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -17,6 +17,7 @@ trait ConfiguresToast { abstract protected function put(string $key, mixed $value): static; + abstract protected function forget(string $key): static; abstract protected function putStyle(string $key, mixed $value): static; abstract protected function putTypography(string $group, array $values): static; @@ -168,9 +169,47 @@ public function dismissible(bool $enabled = true): static public function action(string $label, string $id): static { if (trim($label) === '' || trim($id) === '') throw new InvalidToastConfigurationException('Action label and ID must not be empty.'); + $this->forget('actions'); return $this->put('action', ['id' => $id, 'label' => $label]); } + /** + * @param array $actions + */ + public function actions(array $actions): static + { + if (count($actions) < 1 || count($actions) > 2) { + throw new InvalidToastConfigurationException('Toast actions must contain one or two actions.'); + } + + $normalized = []; + $ids = []; + foreach ($actions as $action) { + if (! is_array($action) || ! isset($action['label'], $action['id']) || ! is_string($action['label']) || ! is_string($action['id'])) { + throw new InvalidToastConfigurationException('Each toast action must specify a string label and ID.'); + } + if (trim($action['label']) === '' || trim($action['id']) === '') { + throw new InvalidToastConfigurationException('Action label and ID must not be empty.'); + } + if (isset($ids[$action['id']])) { + throw new InvalidToastConfigurationException('Toast action IDs must be unique.'); + } + + $ids[$action['id']] = true; + $normalizedAction = ['id' => $action['id'], 'label' => $action['label']]; + if (array_key_exists('color', $action)) { + if (! is_string($action['color'])) { + throw new InvalidToastConfigurationException('Action color must be a hexadecimal color string.'); + } + $normalizedAction['color'] = $this->color($action['color']); + } + $normalized[] = $normalizedAction; + } + + $this->forget('action'); + return $this->put('actions', $normalized); + } + public function data(array $data): static { try { diff --git a/src/PendingToast.php b/src/PendingToast.php index 942535c..1d24aa4 100644 --- a/src/PendingToast.php +++ b/src/PendingToast.php @@ -63,6 +63,12 @@ protected function put(string $key, mixed $value): static return $this; } + protected function forget(string $key): static + { + unset($this->options[$key]); + return $this; + } + protected function putStyle(string $key, mixed $value): static { $this->options['style'][$key] = $value; diff --git a/src/PendingToastUpdate.php b/src/PendingToastUpdate.php index c124479..388c966 100644 --- a/src/PendingToastUpdate.php +++ b/src/PendingToastUpdate.php @@ -42,6 +42,12 @@ protected function put(string $key, mixed $value): static return $this; } + protected function forget(string $key): static + { + unset($this->changes[$key]); + return $this; + } + protected function putStyle(string $key, mixed $value): static { $this->changes['style'][$key] = $value; diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 921a762..d156019 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -66,6 +66,50 @@ expect($this->calls[0][1]['action'])->toBe(['id' => 'retry', 'label' => 'Retry']); }); +it('sets one or two actions with required labels and IDs', function () { + $this->kit->make('Delete draft?')->actions([ + ['label' => 'Cancel', 'id' => 'cancel'], + ['label' => 'Delete', 'id' => 'delete', 'color' => '#ef4444'], + ])->show(); + + expect($this->calls[0][1]['actions'])->toBe([ + ['id' => 'cancel', 'label' => 'Cancel'], + ['id' => 'delete', 'label' => 'Delete', 'color' => '#EF4444'], + ]); +}); + +it('lets the last single or multiple action method win', function () { + $actions = [ + ['label' => 'Cancel', 'id' => 'cancel'], + ['label' => 'Delete', 'id' => 'delete'], + ]; + + $single = $this->kit->make('One')->actions($actions)->action('Undo', 'undo')->payload(); + $multiple = $this->kit->make('Two')->action('Undo', 'undo')->actions($actions)->payload(); + + expect($single)->toHaveKey('action', ['id' => 'undo', 'label' => 'Undo']) + ->not->toHaveKey('actions') + ->and($multiple)->toHaveKey('actions') + ->not->toHaveKey('action'); +}); + +it('rejects invalid multiple actions', function () { + expect(fn () => $this->kit->make('x')->actions([]))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->actions([ + ['label' => 'One', 'id' => 'one'], + ['label' => 'Two', 'id' => 'two'], + ['label' => 'Three', 'id' => 'three'], + ]))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->actions([['label' => 'Missing ID']]))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->actions([ + ['label' => 'One', 'id' => 'same'], + ['label' => 'Two', 'id' => 'same'], + ]))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->actions([ + ['label' => 'One', 'id' => 'one', 'color' => 'red'], + ]))->toThrow(InvalidToastConfigurationException::class); +}); + // ── Presentation ─────────────────────────────────────────────────────────── it('sets the position', function (string $position) { diff --git a/tests/Unit/UpdateTest.php b/tests/Unit/UpdateTest.php index d803794..7d7ed19 100644 --- a/tests/Unit/UpdateTest.php +++ b/tests/Unit/UpdateTest.php @@ -52,6 +52,20 @@ ]); }); +it('updates multiple actions sparsely', function () { + $this->kit->update('upload')->actions([ + ['label' => 'Pause', 'id' => 'pause'], + ['label' => 'Cancel', 'id' => 'cancel'], + ])->show(); + + expect($this->calls[0][1]['changes'])->toBe([ + 'actions' => [ + ['id' => 'pause', 'label' => 'Pause'], + ['id' => 'cancel', 'label' => 'Cancel'], + ], + ]); +}); + it('sends sparse progress and loading updates', function () { $this->kit->update('upload')->progress(35.5)->show(); $this->kit->update('upload')->loading(false)->show();