Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")) }
Expand Down Expand Up @@ -74,6 +75,7 @@ internal object ToastKitBridgeNormalizer {
uniqueKey = nullableString(values["unique_key"], "unique_key"),
nativeIos = nativeMode(values["native"] ?: emptyMap<String, Any>()).first,
nativeAndroid = nativeMode(values["native"] ?: emptyMap<String, Any>()).second,
opacity = values["opacity"]?.let(::opacity),
message = requiredString(values, "message"),
title = nullableString(values["title"], "title"),
variant = variant(values["variant"] ?: "neutral"),
Expand Down Expand Up @@ -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<String, Any>? = 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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand All @@ -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,
Expand Down Expand Up @@ -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))
}
}
1 change: 1 addition & 0 deletions resources/ios/Sources/Model/ToastKitModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions resources/ios/Sources/Presentation/ToastKitHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
Expand Down
8 changes: 7 additions & 1 deletion resources/js/PendingToastBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions resources/js/tests/toastkit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/Concerns/ConfiguresToast.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
51 changes: 51 additions & 0 deletions tests/Unit/OpacityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

use Victorycodedev\ToastKit\PendingToast;

it('omits opacity unless explicitly configured', function () {
$payload = $this->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);
});