diff --git a/README.md b/README.md index 713766b..e41bfd6 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,13 @@ 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) - [Unique Toasts](#unique-toasts) - [Native Appearance](#native-appearance) + - [Dynamic Presentation](#dynamic-presentation) - [Opacity](#opacity) - [Progress \& Loading](#progress--loading) - [Animations \& Direction](#animations--direction) @@ -54,9 +56,10 @@ 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`. +- **Events** — `ToastShown`, `ToastDismissed`, `ToastPressed`, and `ToastActionPressed`. - **JavaScript API** — a fluent `Toast` API for web-facing apps. ## Installation @@ -142,7 +145,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()`, `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 @@ -179,6 +182,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 @@ -387,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 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...') + ->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: @@ -628,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', @@ -645,6 +715,7 @@ Toast::error('Connection lost') public function handleToastAction( string $toastId, string $actionId, + array $data, ): void { if ($actionId === 'retry') { $this->retry(); @@ -652,7 +723,21 @@ 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. + +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: @@ -908,13 +993,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; @@ -1013,7 +1099,10 @@ 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`. | +| `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). | @@ -1025,6 +1114,8 @@ 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. | | `iconColor(string $color)` | Set the icon color. | @@ -1048,6 +1139,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` | @@ -1062,13 +1154,16 @@ Native::test(ProfileScreen::class) | --- | --- | | `variant` | `neutral` | | `position` | `bottom` | +| `presentation` | `standard` | | `duration` | `3000` ms | | `persistent` | `false` | | `animation` | `scale` | | `direction` | `auto` | | `loading` | `false` | +| `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 680d328..4da0835 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) @@ -39,7 +40,9 @@ 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["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 { @@ -51,7 +54,9 @@ 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")) } changes["style"]?.let { next = next.copy(style = style(it, next.style)) } @@ -80,7 +85,9 @@ 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"), + 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"), @@ -88,7 +95,9 @@ 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"), maxVisible = positiveInt(values["max_visible"] ?: 3, "max_visible"), @@ -105,9 +114,40 @@ 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")) + 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 { + 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 { @@ -189,6 +229,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/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 0addb56..00621e2 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -3,15 +3,17 @@ 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 } 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) +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, @@ -39,7 +41,9 @@ internal data class ToastKitConfiguration( val title: String?, val variant: String, val icon: ToastKitIcon?, + val iconSize: ToastKitIconSize?, val position: ToastKitPosition, + val presentation: ToastKitPresentation, val durationMs: Long?, val animation: ToastKitAnimation, val direction: ToastKitDirection, @@ -47,7 +51,8 @@ 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, 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 fe3f155..e18df95 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -5,11 +5,13 @@ 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.clickable 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.foundation.shape.GenericShape import androidx.compose.material3.Text import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -18,6 +20,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 @@ -26,9 +29,13 @@ 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 +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 @@ -43,10 +50,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() @@ -66,6 +74,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) } @@ -80,13 +89,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 }, @@ -94,14 +103,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() @@ -111,7 +121,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() @@ -125,12 +135,21 @@ 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 } + .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) - if (toast.nativeAndroid) { - NativeToastKitCard(toast, displayedProgress, interaction) + if (toast.presentation == ToastKitPresentation.DYNAMIC) { + DynamicToastKitCard(toast, displayedProgress, interaction, reduceMotion, shouldBeVisible) + } else if (toast.nativeAndroid) { + NativeToastKitCard(toast, displayedProgress, interaction, reduceMotion) } else { Column( modifier = interaction @@ -142,38 +161,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) } }) + toast.actions.forEach { action -> + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = action.color ?: 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 +223,162 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { } @Composable -private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: Float, modifier: Modifier) { +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) { + if (reduceMotion) { + dropExtended = false + expanded = true + return@LaunchedEffect + } + dropExtended = true + delay(70) + expanded = true + delay(180) + dropExtended = false + } 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, + modifier = Modifier.weight(1f), + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + 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.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.actions.forEach { action -> + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = action.color ?: 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) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -203,13 +388,23 @@ 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 -> + 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) { @@ -225,14 +420,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)) { @@ -276,5 +475,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..ba2383e 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -42,7 +42,9 @@ 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["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"] { @@ -55,7 +57,9 @@ 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") } if let value = changes["style"] { next.style = try style(value, fallback: next.style) } @@ -82,7 +86,9 @@ 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"), + 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"), @@ -90,7 +96,8 @@ 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"), maxVisible: try positiveInt(values["max_visible"] ?? 3, "max_visible"), @@ -109,7 +116,36 @@ 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] { + 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) { 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 1be42fd..c684974 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -1,12 +1,14 @@ 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 } 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? @@ -16,6 +18,7 @@ struct ToastKitIconConfiguration { struct ToastKitActionConfiguration { let id: String let label: String + let color: Color? } struct ToastKitTypographyConfiguration { @@ -46,7 +49,9 @@ struct ToastKitConfiguration: Identifiable { var title: String? var variant: String var icon: ToastKitIconConfiguration? + var iconSize: ToastKitIconSize? var position: ToastKitPosition + var presentation: ToastKitPresentation var durationMilliseconds: Int? var animation: ToastKitAnimationKind var direction: ToastKitDirection @@ -54,7 +59,8 @@ 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 var maxVisible: Int diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index d5caaff..26c6409 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) + } +} + private struct ToastKitRevealModifier: ViewModifier { let amount: CGFloat let anchor: UnitPoint @@ -92,25 +107,136 @@ 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)))) .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) } + .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 } + if !reduceMotion { try? await Task.sleep(for: .milliseconds(180)) } + withAnimation(dynamicAnimation) { dynamicDropExtended = false } + } + } + + 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) + + 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(action.color ?? 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 { @@ -119,28 +245,35 @@ 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 { + 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: { - 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,18 +302,31 @@ 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 { + 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: { - Image(systemName: "xmark").frame(width: 28, height: 28) + Image(systemName: "xmark").frame(minWidth: 44, minHeight: 44) } .buttonStyle(.plain) .accessibilityLabel("Dismiss") @@ -201,19 +347,56 @@ 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 } } } + + 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 { @@ -224,6 +407,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 { @@ -283,6 +470,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/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 84d2a63..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")); } @@ -52,9 +56,18 @@ 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")); } + 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) }); } @@ -93,11 +106,45 @@ 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"); + 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 b5668bf..ebd8810 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") @@ -85,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 }); @@ -155,6 +173,51 @@ 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); + 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/resources/js/validation.js b/resources/js/validation.js index b17e930..b4529ad 100644 --- a/resources/js/validation.js +++ b/resources/js/validation.js @@ -1,12 +1,14 @@ 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"], 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..4524bbf 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; @@ -16,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; @@ -87,11 +89,30 @@ 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')); } + 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]); @@ -148,9 +169,58 @@ 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 { + 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/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 @@ +options[$key]); + return $this; + } + protected function putStyle(string $key, mixed $value): static { $this->options['style'][$key] = $value; @@ -81,6 +87,7 @@ private function defaults(): array 'title' => null, 'variant' => 'neutral', 'position' => 'bottom', + 'presentation' => 'standard', 'native' => ['ios' => false, 'android' => false], 'duration' => 3000, 'persistent' => false, 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/AttachedDataTest.php b/tests/Unit/AttachedDataTest.php new file mode 100644 index 0000000..2f6b520 --- /dev/null +++ b/tests/Unit/AttachedDataTest.php @@ -0,0 +1,59 @@ +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]); +}); 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/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 2c6a0f9..d156019 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -49,12 +49,67 @@ 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(); 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/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/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'); +}); diff --git a/tests/Unit/UpdateTest.php b/tests/Unit/UpdateTest.php index 66dfb33..7d7ed19 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(); @@ -46,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();