diff --git a/CHANGELOG.md b/CHANGELOG.md index 93644db..47870bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Optional whole-toast `opacity()` styling with clamping, sparse updates, preset support, and PHP/JavaScript parity. - Platform-selective `native()` rendering with iOS Liquid Glass/native material fallback, Android Material 3 appearance, sparse update support, presets, and JavaScript parity. - Unique toasts through `unique()`, `updateUnique()`, and `dismissUnique()`, with native lifecycle-aware deduplication across visible, queued, stacked, and persistent states on Android and iOS. - JavaScript parity for unique toast creation, updates, dismissal, and bridge-resolved UUIDs. diff --git a/README.md b/README.md index e3cf442..713766b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - [Updating Toasts](#updating-toasts) - [Unique Toasts](#unique-toasts) - [Native Appearance](#native-appearance) + - [Opacity](#opacity) - [Progress \& Loading](#progress--loading) - [Animations \& Direction](#animations--direction) - [Presets](#presets) @@ -386,6 +387,24 @@ 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. +## Opacity + +Opacity is optional and applies to the entire toast, including its content and controls: + +```php +Toast::success('Saved')->opacity()->show(); // 0.8 +Toast::success('Saved')->opacity(0.5)->show(); // 50% +Toast::success('Saved')->opacity(1.0)->show(); // Fully opaque +``` + +If `opacity()` is never called, ToastKit behaves exactly as before. Accepted values are `0.0`–`1.0`; values outside that range are clamped. Opacity also works in presets and sparse updates: + +```php +Toast::definePreset('subtle', fn ($toast) => $toast->opacity()->animation('fade')); + +Toast::update($id)->opacity(0.9)->show(); +``` + ## Progress & Loading Use determinate progress for work with a known percentage. Values are clamped to `0`–`100`, and updates animate without replacing the toast: @@ -996,6 +1015,7 @@ 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). | | `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. | | `duration(int $milliseconds)` | Set the visible duration (makes the toast timed). | | `persistent(bool $persistent = true)` | Make the toast persistent (no timeout). | | `animation(ToastAnimation\|string $animation)` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, or `bounce`. | 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 871de1d..680d328 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -46,6 +46,7 @@ internal object ToastKitBridgeNormalizer { val mode = nativeMode(it) next = next.copy(nativeIos = mode.first, nativeAndroid = mode.second) } + if (changes.containsKey("opacity")) next = next.copy(opacity = opacity(changes["opacity"] ?: throw ToastKitInputException("opacity must be numeric"))) if (changes.containsKey("progress")) next = next.copy(progress = optionalValue(changes["progress"])?.let(::progress)) changes["loading"]?.let { next = next.copy(loading = boolean(it, "loading")) } changes["swipe_to_dismiss"]?.let { next = next.copy(swipeToDismiss = boolean(it, "swipe_to_dismiss")) } @@ -74,6 +75,7 @@ internal object ToastKitBridgeNormalizer { uniqueKey = nullableString(values["unique_key"], "unique_key"), nativeIos = nativeMode(values["native"] ?: emptyMap()).first, nativeAndroid = nativeMode(values["native"] ?: emptyMap()).second, + opacity = values["opacity"]?.let(::opacity), message = requiredString(values, "message"), title = nullableString(values["title"], "title"), variant = variant(values["variant"] ?: "neutral"), @@ -227,6 +229,11 @@ internal object ToastKitBridgeNormalizer { requireInput(result.isFinite(), "progress must be finite") return result.coerceIn(0f, 100f) } + private fun opacity(value: Any): Float { + val result = (value as? Number)?.toFloat() ?: throw ToastKitInputException("opacity must be numeric") + requireInput(result.isFinite(), "opacity must be finite") + return result.coerceIn(0f, 1f) + } private fun stringMap(value: Any?): Map? = when (value) { is JSONObject -> value.keys().asSequence().associate { key -> key to value.get(key) } is Map<*, *> -> value.entries.associate { (key, v) -> key.toString() to (v ?: JSONObject.NULL) } diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt index 02a39b6..0addb56 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -34,6 +34,7 @@ internal data class ToastKitConfiguration( val uniqueKey: String?, val nativeIos: Boolean, val nativeAndroid: Boolean, + val opacity: Float?, val message: String, val title: String?, val variant: String, 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 02b6cf9..fe3f155 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.input.pointer.pointerInput @@ -126,6 +127,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { AnimatedVisibility(visibleState = visibility, enter = enter, exit = exit) { val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp).fillMaxWidth() .graphicsLayer { translationX = displayedX; translationY = displayedY; alpha = (1f - (abs(displayedX) + abs(displayedY)) / 700f).coerceIn(.35f, 1f) } + .then(toast.opacity?.let { Modifier.alpha(it) } ?: Modifier) .then(gesture) if (toast.nativeAndroid) { NativeToastKitCard(toast, displayedProgress, interaction) diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index 295349a..fe56862 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -50,6 +50,7 @@ enum ToastKitBridgeNormalizer { next.nativeIos = mode.ios next.nativeAndroid = mode.android } + if let value = changes["opacity"] { next.opacity = try opacity(value) } if changes.keys.contains("progress") { next.progress = try changes["progress"].map(progress) } if let value = changes["loading"] { next.loading = try boolean(value, "loading") } if let value = changes["swipe_to_dismiss"] { next.swipeToDismiss = try boolean(value, "swipe_to_dismiss") } @@ -76,6 +77,7 @@ enum ToastKitBridgeNormalizer { uniqueKey: try nullableString(values["unique_key"], "unique_key"), nativeIos: try nativeMode(values["native"] ?? [String: Any]()).ios, nativeAndroid: try nativeMode(values["native"] ?? [String: Any]()).android, + opacity: try values["opacity"].map(opacity), message: try requiredString(values, "message"), title: try nullableString(values["title"], "title"), variant: variantName, @@ -226,4 +228,10 @@ enum ToastKitBridgeNormalizer { } return min(100, max(0, result)) } + private static func opacity(_ value: Any) throws -> Double { + guard let result = (value as? NSNumber)?.doubleValue, result.isFinite else { + throw ToastKitInputError.invalid("opacity must be a finite number") + } + return min(1, max(0, result)) + } } diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index fd88646..1be42fd 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -41,6 +41,7 @@ struct ToastKitConfiguration: Identifiable { let uniqueKey: String? var nativeIos: Bool var nativeAndroid: Bool + var opacity: Double? var message: String var title: String? var variant: String diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index 67d8d84..d5caaff 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -99,6 +99,7 @@ private struct ToastKitView: View { } .offset(drag) .opacity(Double(max(CGFloat(0.35), CGFloat(1) - (abs(drag.width) + abs(drag.height)) / CGFloat(700)))) + .toastKitConfiguredOpacity(toast.opacity) .contentShape(Rectangle()) .background { GeometryReader { proxy in @@ -224,6 +225,11 @@ private struct ToastKitFramePreferenceKey: PreferenceKey { } private extension View { + @ViewBuilder + func toastKitConfiguredOpacity(_ opacity: Double?) -> some View { + if let opacity { self.opacity(opacity) } else { self } + } + @ViewBuilder func toastKitConditional(_ condition: Bool, transform: (Self) -> Content) -> some View { if condition { transform(self) } else { self } diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index 2f03180..84d2a63 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -58,6 +58,11 @@ export class PendingToastBase { native({ ios = true, android = true } = {}) { return this.set("native", { ios: Boolean(ios), android: Boolean(android) }); } + opacity(value = 0.8) { + if (!Number.isFinite(value)) + throw new TypeError("opacity must be a finite number"); + return this.set("opacity", Math.max(0, Math.min(1, value))); + } duration(value) { this.set("persistent", false); return this.set("duration", positiveInteger(value, "duration")); @@ -74,7 +79,8 @@ export class PendingToastBase { return this.set("direction", oneOf(value, "direction")); } progress(value) { - if (!Number.isFinite(value)) throw new TypeError("progress must be a finite number"); + if (!Number.isFinite(value)) + throw new TypeError("progress must be a finite number"); return this.set("progress", Math.max(0, Math.min(100, value))); } loading(enabled = true) { diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index 0e3a94d..b5668bf 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -106,6 +106,23 @@ test("native appearance can be changed through a sparse update", () => { assert.deepEqual(payload.changes, { native: { ios: false, android: true } }); }); +test("opacity is opt-in, defaults to 0.8, preserves zero, and clamps", () => { + assert.equal("opacity" in Toast.success("Default").payload(), false); + assert.equal(Toast.success("Subtle").opacity().payload().opacity, 0.8); + assert.equal(Toast.success("Half").opacity(0.5).payload().opacity, 0.5); + assert.equal(Toast.success("Hidden").opacity(0).payload().opacity, 0); + assert.equal(Toast.success("Opaque").opacity(1).payload().opacity, 1); + assert.equal(Toast.success("Low").opacity(-1).payload().opacity, 0); + assert.equal(Toast.success("High").opacity(2).payload().opacity, 1); +}); + +test("opacity is sparse in updates and omitted updates do not reset it", () => { + assert.deepEqual(Toast.update("toast").opacity(0).payload().changes, { opacity: 0 }); + assert.deepEqual(Toast.update("toast").message("Almost finished").payload().changes, { + message: "Almost finished", + }); +}); + test("new animations, direction, progress and loading are set", () => { for (const animation of ["snap", "pop", "reveal", "bounce"]) assert.equal(Toast.make("x").animation(animation).payload().animation, animation); diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 66fdb39..6ea92e0 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -97,6 +97,11 @@ public function native(bool $ios = true, bool $android = true): static return $this->put('native', ['ios' => $ios, 'android' => $android]); } + public function opacity(float $opacity = 0.8): static + { + return $this->put('opacity', max(0.0, min(1.0, $opacity))); + } + public function duration(int $milliseconds): static { if ($milliseconds <= 0) throw new InvalidToastConfigurationException('Toast duration must be greater than zero milliseconds.'); diff --git a/tests/Unit/OpacityTest.php b/tests/Unit/OpacityTest.php new file mode 100644 index 0000000..4e969df --- /dev/null +++ b/tests/Unit/OpacityTest.php @@ -0,0 +1,51 @@ +kit->success('Saved')->payload(); + + expect($payload)->not->toHaveKey('opacity'); +}); + +it('uses 0.8 when opacity is called without an argument', function () { + $this->kit->success('Saved')->opacity()->show(); + + expect($this->calls[0][1]['opacity'])->toBe(0.8); +}); + +it('preserves explicit opacity values', function (float $opacity) { + $this->kit->success('Saved')->opacity($opacity)->show(); + + expect($this->calls[0][1]['opacity'])->toBe($opacity); +})->with([0.0, 0.5, 0.65, 1.0]); + +it('clamps opacity to the supported range', function (float $input, float $expected) { + $this->kit->success('Saved')->opacity($input)->show(); + + expect($this->calls[0][1]['opacity'])->toBe($expected); +})->with([ + 'below zero' => [-1.0, 0.0], + 'above one' => [2.0, 1.0], +]); + +it('sends opacity as a sparse update and preserves zero', function () { + $this->kit->update('toast')->opacity(0.0)->show(); + $this->kit->update('toast')->message('Almost finished')->show(); + + expect($this->calls[0][1]['changes'])->toBe(['opacity' => 0.0]) + ->and($this->calls[1][1]['changes'])->toBe(['message' => 'Almost finished']); +}); + +it('supports preset opacity and explicit per-toast overrides', function () { + $this->kit->definePreset('subtle', fn(PendingToast $toast) => $toast + ->opacity() + ->position('bottom') + ->animation('fade')); + + $this->kit->preset('subtle')->message('Copied')->show(); + $this->kit->preset('subtle')->message('Saved')->opacity(0.45)->show(); + + expect($this->calls[0][1]['opacity'])->toBe(0.8) + ->and($this->calls[1][1]['opacity'])->toBe(0.45); +});