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
2 changes: 2 additions & 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

- NativePHP custom SVG icon support through `custom:<name>`, including shared icons, mixed SF Symbol/Material/custom platform overrides, tinting, sizing, validation, and missing-asset fallback behavior.
- 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.
Expand All @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- NativePHP Mobile minimum requirement raised from `^4.1` to `^4.7` for the custom icon build and runtime asset pipeline.
- **Breaking:** ToastKit's public exceptions now extend the package-owned `ToastKitException` base, which extends `RuntimeException`. Replace ToastKit-specific `InvalidArgumentException` catches with `ToastKitException`.
- Composer requirement raised to PHP `^8.4` and NativePHP Mobile `^4.1`.
- Pest upgraded to `^4.0`.
53 changes: 50 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift
- [Custom Toasts](#custom-toasts)
- [Icons](#icons)
- [Platform overrides](#platform-overrides)
- [Custom SVG icons](#custom-svg-icons)
- [Icon size](#icon-size)
- [Typography](#typography)
- [Font resolution](#font-resolution)
Expand Down Expand Up @@ -50,7 +51,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift
## Feature Highlights

- **Five variants** — `success`, `error`, `warning`, `info`, and `neutral`.
- **Rich content** — title, message, and icons with per-platform overrides.
- **Rich content** — title, message, system icons, custom SVG icons, and per-platform overrides.
- **Full customization** — position, animation direction, progress, loading, swipe-to-dismiss, close control, colors, corner radius, padding, and shadow.
- **Action buttons** — a native action button with its own ID and pressed event.
- **Queue strategy** — FIFO, one toast at a time.
Expand Down Expand Up @@ -182,6 +183,52 @@ You can pass either override on its own:
Toast::make('Rated')->icon(ios: 'star.fill')->show();
```

### Custom SVG icons

ToastKit supports the custom SVG icons built by NativePHP Mobile 4.7 and later. Put a single-color SVG directly in your application's `resources/icons` directory:

```text
resources/icons/payment.svg
```

Then reference it with NativePHP's `custom:` prefix:

```php
use Native\Mobile\Icon\Icon;

Toast::make('Payment successful')
->icon('custom:payment')
->iconColor('#22C55E')
->show();

// NativePHP's helper returns the same `custom:payment` string.
Toast::make('Payment successful')->icon(Icon::custom('payment'))->show();
```

The shared name can be custom, or custom and platform icons can be mixed freely. A platform override wins on that platform and `name` remains the fallback:

```php
Toast::make('Payment successful')
->icon(
name: 'check', // shared fallback
ios: 'checkmark.circle.fill', // SF Symbol
android: 'custom:payment-android', // custom SVG
)
->show();

Toast::make('Payment successful')
->icon(
name: 'custom:payment', // custom on both by default
ios: 'custom:payment-ios', // custom iOS override
android: 'verified', // Material Icon override
)
->show();
```

Custom icon filenames must contain lowercase letters and numbers joined by single hyphens, such as `payment-card.svg`; subdirectories, underscores, uppercase letters, and consecutive hyphens are not supported. NativePHP compiles the SVG into an iOS template asset and Android `VectorDrawable`, so run a full native build after adding or changing an icon—hot reload is not enough.

Custom icons are single-color and use `iconColor()`. `iconSize()` works with them in ToastKit's standard and dynamic renderers. If an asset is unavailable, including in Jump, NativePHP logs a warning and falls back to the system icon matching the bare name. See NativePHP's [custom icon requirements and supported SVG features](https://nativephp.com/docs/mobile/4/edge-components/icon#custom-icons).

### Icon size

Choose one of three icon sizes for the custom renderer:
Expand Down Expand Up @@ -1098,7 +1145,7 @@ Native::test(ProfileScreen::class)
| `titleText(?string $font = null, $size = null, $weight = null, $align = null, ?bool $italic = null)` | Configure title typography. See [Typography](#typography). |
| `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). |
| `icon(?string $name = null, ?string $ios = null, ?string $android = null)` | Set a shared, platform-specific, or `custom:` SVG icon. Custom and system icons can be mixed across platforms. 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. |
Expand Down Expand Up @@ -1175,7 +1222,7 @@ Native::test(ProfileScreen::class)
| Requirement | Version |
| --- | --- |
| PHP | 8.4+ |
| NativePHP Mobile | 4.1+ |
| NativePHP Mobile | 4.7+ |
| Android | API 29+ (Android 10) |
| iOS | 18.0+ |

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
],
"require": {
"php": "^8.4",
"nativephp/mobile": "^4.1"
"nativephp/mobile": "^4.7"
},
"autoload": {
"psr-4": {
Expand Down
34 changes: 28 additions & 6 deletions resources/ios/Sources/Presentation/ToastKitHost.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import SwiftUI

private struct ToastKitIconView: View {
let name: String
let pointSize: CGFloat

var body: some View {
if let asset = NativeIcon.customAssetName(name) {
Image(asset)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: pointSize, height: pointSize)
} else {
Image(systemName: getIconForName(name))
.font(.system(size: pointSize, weight: .semibold))
}
}
}

struct ToastKitHost: View {
@ObservedObject private var manager = ToastKitManager.shared
@Environment(\.accessibilityReduceMotion) private var reduceMotion
Expand Down Expand Up @@ -166,8 +184,10 @@ private struct ToastKitView: View {
.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))
ToastKitIconView(
name: icon?.ios ?? icon?.name ?? "bell.fill",
pointSize: toastKitIconSize(toast.iconSize, fallback: 20)
)
.foregroundStyle(toast.style.iconColor)
.frame(width: 24, height: 24)
.accessibilityHidden(true)
Expand Down Expand Up @@ -244,8 +264,11 @@ private struct ToastKitView: View {
if toast.progress == nil && toast.loading {
ProgressView().tint(toast.style.iconColor).controlSize(.small)
} else if let icon = toast.icon {
Image(systemName: icon.ios ?? getIconForName(icon.name ?? "circle"))
.font(.system(size: toastKitIconSize(toast.iconSize, fallback: 21), weight: .semibold)).foregroundStyle(toast.style.iconColor)
ToastKitIconView(
name: icon.ios ?? icon.name ?? "circle",
pointSize: toastKitIconSize(toast.iconSize, fallback: 21)
)
.foregroundStyle(toast.style.iconColor)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
Expand Down Expand Up @@ -297,8 +320,7 @@ private struct ToastKitView: View {
if toast.progress == nil && toast.loading {
ProgressView().controlSize(.small)
} else if let icon = toast.icon {
Image(systemName: icon.ios ?? getIconForName(icon.name ?? "circle"))
.font(.title3.weight(.semibold))
ToastKitIconView(name: icon.ios ?? icon.name ?? "circle", pointSize: 20)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
Expand Down
14 changes: 11 additions & 3 deletions resources/js/PendingToastBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ export class PendingToastBase {
}
icon(name = null, { ios = null, android = null } = {}) {
const icon = {};
if (name !== null) icon.name = nonEmpty(name, "icon name");
if (ios !== null) icon.ios = nonEmpty(ios, "iOS icon");
if (android !== null) icon.android = nonEmpty(android, "Android icon");
if (name !== null) icon.name = customIconName(name, "icon name");
if (ios !== null) icon.ios = customIconName(ios, "iOS icon");
if (android !== null)
icon.android = customIconName(android, "Android icon");
if (!Object.keys(icon).length)
throw new TypeError("An icon name or platform override is required");
return this.set("icon", icon);
Expand Down Expand Up @@ -202,3 +203,10 @@ export class PendingToastBase {
return this.set("max_visible", positiveInteger(value, "maxVisible"));
}
}

function customIconName(value, label) {
const name = nonEmpty(value, label);
if (name.startsWith("custom:") && !/^custom:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
throw new TypeError(`${label} custom icon must use custom: followed by a lowercase kebab-case name`);
return name;
}
78 changes: 78 additions & 0 deletions resources/js/tests/toastkit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,84 @@ test("icon supports logical names and platform overrides", () => {
);
});

test("icon supports shared custom icons and mixed platform icons", () => {
assert.deepEqual(Toast.make("x").icon("custom:payment").payload().icon, {
name: "custom:payment",
});
assert.deepEqual(
Toast.make("x")
.icon("check", {
ios: "checkmark.circle.fill",
android: "custom:payment-android",
})
.payload().icon,
{
name: "check",
ios: "checkmark.circle.fill",
android: "custom:payment-android",
},
);
assert.deepEqual(
Toast.make("x")
.icon("custom:payment", {
ios: "custom:payment-ios",
android: "verified",
})
.payload().icon,
{
name: "custom:payment",
ios: "custom:payment-ios",
android: "verified",
},
);
});

test("icon rejects invalid custom icon names", () => {
for (const icon of [
"custom:",
"custom:Payment",
"custom:payment_icon",
"custom:icons/payment",
"custom:payment--icon",
]) {
assert.throws(() => Toast.make("x").icon(icon), /lowercase kebab-case/);
}
assert.throws(
() => Toast.make("x").icon("check", { ios: "custom:Payment" }),
/lowercase kebab-case/,
);
assert.throws(
() => Toast.make("x").icon("check", { android: "custom:payment_icon" }),
/lowercase kebab-case/,
);
});

test("icon supports custom platform overrides without a shared name", () => {
assert.deepEqual(
Toast.make("x").icon(null, { ios: "custom:payment-ios" }).payload().icon,
{ ios: "custom:payment-ios" },
);
assert.deepEqual(
Toast.make("x").icon(null, { android: "custom:payment-android" }).payload()
.icon,
{ android: "custom:payment-android" },
);
});

test("update sends custom icons sparsely", () => {
assert.deepEqual(
Toast.update("one")
.icon("custom:payment", { android: "custom:payment-android" })
.payload().changes,
{
icon: {
name: "custom:payment",
android: "custom:payment-android",
},
},
);
});

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, {
Expand Down
21 changes: 18 additions & 3 deletions src/Concerns/ConfiguresToast.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Victorycodedev\ToastKit\Concerns;

use BackedEnum;
use Native\Mobile\Icon\Icon;
use Victorycodedev\ToastKit\Enums\ToastAnimation;
use Victorycodedev\ToastKit\Enums\ToastDirection;
use Victorycodedev\ToastKit\Enums\ToastPosition;
Expand Down Expand Up @@ -81,9 +82,9 @@ public function variant(ToastVariant|string $variant): static
public function icon(?string $name = null, ?string $ios = null, ?string $android = null): static
{
$icon = array_filter([
'name' => $this->nullableNonEmpty($name, 'Icon name'),
'ios' => $this->nullableNonEmpty($ios, 'iOS icon name'),
'android' => $this->nullableNonEmpty($android, 'Android icon name'),
'name' => $this->iconName($name, 'Icon name'),
'ios' => $this->iconName($ios, 'iOS icon name'),
'android' => $this->iconName($android, 'Android icon name'),
], static fn($value) => $value !== null);
if ($icon === []) throw new InvalidToastConfigurationException('An icon name or platform override is required.');
return $this->put('icon', $icon);
Expand Down Expand Up @@ -282,6 +283,20 @@ private function color(string $color): string
return strtoupper($color);
}

private function iconName(?string $value, string $label): ?string
{
$value = $this->nullableNonEmpty($value, $label);

if ($value !== null && Icon::isCustom($value)
&& ! Icon::isValidName(substr($value, strlen(Icon::PREFIX)))) {
throw new InvalidToastConfigurationException(
"{$label} custom icon must use custom: followed by a lowercase kebab-case name."
);
}

return $value;
}

private function enumValue(string $enum, BackedEnum|string $value, string $label): string
{
if ($value instanceof $enum) return $value->value;
Expand Down
38 changes: 38 additions & 0 deletions tests/Unit/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,50 @@
]);
});

it('supports custom icons shared and mixed with platform icons', function () {
$shared = $this->kit->make('Shared')->icon('custom:payment')->payload();
$mixed = $this->kit->make('Mixed')
->icon('check', ios: 'checkmark.circle.fill', android: 'custom:payment-android')
->payload();
$reverse = $this->kit->make('Reverse')
->icon('custom:payment', ios: 'custom:payment-ios', android: 'verified')
->payload();

expect($shared['icon'])->toBe(['name' => 'custom:payment'])
->and($mixed['icon'])->toBe([
'name' => 'check',
'ios' => 'checkmark.circle.fill',
'android' => 'custom:payment-android',
])
->and($reverse['icon'])->toBe([
'name' => 'custom:payment',
'ios' => 'custom:payment-ios',
'android' => 'verified',
]);
});

it('rejects invalid custom icon names in every icon slot', function () {
expect(fn () => $this->kit->make('x')->icon('custom:'))->toThrow(InvalidToastConfigurationException::class)
->and(fn () => $this->kit->make('x')->icon(ios: 'custom:Payment'))->toThrow(InvalidToastConfigurationException::class)
->and(fn () => $this->kit->make('x')->icon(android: 'custom:payment_icon'))->toThrow(InvalidToastConfigurationException::class)
->and(fn () => $this->kit->make('x')->icon('custom:icons/payment'))->toThrow(InvalidToastConfigurationException::class)
->and(fn () => $this->kit->make('x')->icon('custom:payment--icon'))->toThrow(InvalidToastConfigurationException::class);
});

it('sets a platform override without a logical name', function () {
$this->kit->make('Saved')->icon(ios: 'star.fill')->show();

expect($this->calls[0][1]['icon'])->toBe(['ios' => 'star.fill']);
});

it('sets custom platform overrides without a logical name', function () {
$ios = $this->kit->make('iOS')->icon(ios: 'custom:payment-ios')->payload();
$android = $this->kit->make('Android')->icon(android: 'custom:payment-android')->payload();

expect($ios['icon'])->toBe(['ios' => 'custom:payment-ios'])
->and($android['icon'])->toBe(['android' => 'custom:payment-android']);
});

it('rejects an icon with no name or overrides', function () {
expect(fn () => $this->kit->make('x')->icon())->toThrow(InvalidToastConfigurationException::class);
});
Expand Down
13 changes: 13 additions & 0 deletions tests/Unit/UpdateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
]);
});

it('updates a custom icon sparsely', function () {
$this->kit->update('upload')
->icon('custom:payment', android: 'custom:payment-android')
->show();

expect($this->calls[0][1]['changes'])->toBe([
'icon' => [
'name' => 'custom:payment',
'android' => 'custom:payment-android',
],
]);
});

it('updates icon size sparsely', function () {
$this->kit->update('upload')->iconSize('lg')->show();

Expand Down
Loading