diff --git a/docs/05-panel-configuration.md b/docs/05-panel-configuration.md index 2c6987bf07..f436acfa27 100644 --- a/docs/05-panel-configuration.md +++ b/docs/05-panel-configuration.md @@ -218,6 +218,33 @@ public function panel(Panel $panel): Panel } ``` +### Enabling SPA prefetching + +SPA prefetching enhances the user experience by automatically prefetching pages when users hover over links, making navigation feel even more responsive. This feature utilizes [Livewire's `wire:navigate.hover` functionality](https://livewire.laravel.com/docs/navigate#prefetching-links). + +To enable SPA mode with prefetching, you can pass the `hasPrefetching: true` parameter to the `spa()` method: + +```php +use Filament\Panel; + +public function panel(Panel $panel): Panel +{ + return $panel + // ... + ->spa(hasPrefetching: true); +} +``` + +When prefetching is enabled, all links within your panel will automatically include `wire:navigate.hover`, which prefetches the page content when users hover over the link. This works seamlessly with [URL exceptions](#disabling-spa-navigation-for-specific-urls) - any URLs excluded from SPA mode will also be excluded from prefetching. + + + + + ## Unsaved changes alerts You may alert users if they attempt to navigate away from a page without saving their changes. This is applied on [Create](resources/creating-records) and [Edit](resources/editing-records) pages of a resource, as well as any open action modals. To enable this feature, you can use the `unsavedChangesAlerts()` method: diff --git a/packages/panels/src/Panel.php b/packages/panels/src/Panel.php index 70b828ad63..08505f3d41 100644 --- a/packages/panels/src/Panel.php +++ b/packages/panels/src/Panel.php @@ -97,7 +97,7 @@ class Panel extends Component FilamentIcon::register($this->getIcons()); - FilamentView::spa($this->hasSpaMode()); + FilamentView::spa($this->hasSpaMode(), $this->hasSpaPrefetching()); FilamentView::spaUrlExceptions($this->getSpaUrlExceptions()); $this->registerRenderHooks(); diff --git a/packages/panels/src/Panel/Concerns/HasSpaMode.php b/packages/panels/src/Panel/Concerns/HasSpaMode.php index 01bd89432c..8bc9014bd4 100644 --- a/packages/panels/src/Panel/Concerns/HasSpaMode.php +++ b/packages/panels/src/Panel/Concerns/HasSpaMode.php @@ -8,14 +8,17 @@ trait HasSpaMode { protected bool | Closure $hasSpaMode = false; + protected bool | Closure $hasSpaPrefetching = false; + /** * @var array | Closure */ protected array | Closure $spaModeUrlExceptions = []; - public function spa(bool | Closure $condition = true): static + public function spa(bool | Closure $condition = true, bool | Closure $hasPrefetching = false): static { $this->hasSpaMode = $condition; + $this->hasSpaPrefetching = $hasPrefetching; return $this; } @@ -35,6 +38,11 @@ trait HasSpaMode return (bool) $this->evaluate($this->hasSpaMode); } + public function hasSpaPrefetching(): bool + { + return (bool) $this->evaluate($this->hasSpaPrefetching); + } + /** * @return array */ diff --git a/packages/support/src/Facades/FilamentView.php b/packages/support/src/Facades/FilamentView.php index 2114b5cf66..f59695d2a6 100644 --- a/packages/support/src/Facades/FilamentView.php +++ b/packages/support/src/Facades/FilamentView.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Facade; /** * @method static bool hasSpaMode(?string $url = null) + * @method static bool hasSpaPrefetching() * @method static Htmlable renderHook(string $name, string | array | null $scopes = null) * * @see ViewManager @@ -30,10 +31,10 @@ class FilamentView extends Facade }); } - public static function spa(bool $condition = true): void + public static function spa(bool $condition = true, bool $hasPrefetching = false): void { - static::resolved(function (ViewManager $viewManager) use ($condition): void { - $viewManager->spa($condition); + static::resolved(function (ViewManager $viewManager) use ($condition, $hasPrefetching): void { + $viewManager->spa($condition, $hasPrefetching); }); } diff --git a/packages/support/src/View/ViewManager.php b/packages/support/src/View/ViewManager.php index 7f589409a2..1d5eb328df 100644 --- a/packages/support/src/View/ViewManager.php +++ b/packages/support/src/View/ViewManager.php @@ -18,6 +18,8 @@ class ViewManager protected bool $hasSpaMode = false; + protected bool $hasSpaPrefetching = false; + /** * @var array */ @@ -76,9 +78,10 @@ class ViewManager return new HtmlString(implode('', $hooks)); } - public function spa(bool $condition = true): void + public function spa(bool $condition = true, bool $hasPrefetching = false): void { $this->hasSpaMode = $condition; + $this->hasSpaPrefetching = $hasPrefetching; } /** @@ -108,4 +111,9 @@ class ViewManager return is_app_url($url); } + + public function hasSpaPrefetching(): bool + { + return $this->hasSpaPrefetching; + } } diff --git a/packages/support/src/helpers.php b/packages/support/src/helpers.php index 4b1f6343ba..c24ea4c7cd 100644 --- a/packages/support/src/helpers.php +++ b/packages/support/src/helpers.php @@ -133,6 +133,10 @@ if (! function_exists('Filament\Support\generate_href_html')) { $html .= ' target="_blank"'; } elseif ($shouldOpenInSpaMode ?? (FilamentView::hasSpaMode($url))) { $html .= ' wire:navigate'; + + if (FilamentView::hasSpaPrefetching()) { + $html .= '.hover'; + } } return new HtmlString($html); diff --git a/tests/src/Support/SpaModeTest.php b/tests/src/Support/SpaModeTest.php index 5605606230..20375d75d6 100644 --- a/tests/src/Support/SpaModeTest.php +++ b/tests/src/Support/SpaModeTest.php @@ -17,6 +17,16 @@ test('SPA mode can be toggled', function (): void { expect(FilamentView::hasSpaMode())->toBeFalse(); }); +test('SPA prefetching can be toggled', function (): void { + expect(FilamentView::hasSpaPrefetching())->toBeFalse(); + + FilamentView::spa(true, true); + expect(FilamentView::hasSpaPrefetching())->toBeTrue(); + + FilamentView::spa(true, false); + expect(FilamentView::hasSpaPrefetching())->toBeFalse(); +}); + test('`href` HTML can be generated with `wire:navigate` based on SPA mode', function (): void { FilamentView::spa(); expect(generate_href_html('http://localhost/page')) @@ -27,6 +37,16 @@ test('`href` HTML can be generated with `wire:navigate` based on SPA mode', func ->toHtml()->toBe('href="http://localhost/page"'); }); +test('`href` HTML can be generated with `wire:navigate.hover` when prefetching is enabled', function (): void { + FilamentView::spa(true, true); + expect(generate_href_html('http://localhost/page')) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate.hover'); + + FilamentView::spa(true, false); + expect(generate_href_html('http://localhost/page')) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate'); +}); + test('`wire:navigate` is not used in the `href` HTML if it doesn\'t match the request\'s domain', function (): void { FilamentView::spa(); expect(generate_href_html('http://another-localhost/page')) @@ -37,6 +57,12 @@ test('`wire:navigate` is not used in the `href` HTML if it doesn\'t match the re ->toHtml()->toBe('href="http://another-localhost/page"'); }); +test('`wire:navigate.hover` is not used for external URLs even when prefetching is enabled', function (): void { + FilamentView::spa(true, true); + expect(generate_href_html('http://another-localhost/page')) + ->toHtml()->toBe('href="http://another-localhost/page"'); +}); + test('`target` HTML can be generated if the URL should open in a new tab', function (): void { FilamentView::spa(); expect(generate_href_html('http://localhost/page', shouldOpenInNewTab: true)) @@ -46,3 +72,122 @@ test('`target` HTML can be generated if the URL should open in a new tab', funct expect(generate_href_html('http://localhost/page', shouldOpenInNewTab: true)) ->toHtml()->toBe('href="http://localhost/page" target="_blank"'); }); + +test('`target` HTML takes precedence over SPA prefetching', function (): void { + FilamentView::spa(true, true); + expect(generate_href_html('http://localhost/page', shouldOpenInNewTab: true)) + ->toHtml()->toBe('href="http://localhost/page" target="_blank"'); +}); + +test('SPA URL exceptions work correctly', function (): void { + FilamentView::spa(); + FilamentView::spaUrlExceptions(['*/admin/*']); + + expect(FilamentView::hasSpaMode('http://localhost/admin/users'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/dashboard'))->toBeTrue(); +}); + +test('`href` HTML respects SPA URL exceptions for prefetching', function (): void { + FilamentView::spa(true, true); + FilamentView::spaUrlExceptions(['*/admin/*']); + + expect(generate_href_html('http://localhost/admin/users')) + ->toHtml()->toBe('href="http://localhost/admin/users"'); + + expect(generate_href_html('http://localhost/dashboard')) + ->toHtml()->toBe('href="http://localhost/dashboard" wire:navigate.hover'); +}); + +test('`shouldOpenInSpaMode` parameter overrides default SPA mode behavior', function (): void { + FilamentView::spa(false); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: true)) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate'); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: false)) + ->toHtml()->toBe('href="http://localhost/page"'); +}); + +test('`shouldOpenInSpaMode` parameter works with prefetching', function (): void { + FilamentView::spa(true, true); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: true)) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate.hover'); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: false)) + ->toHtml()->toBe('href="http://localhost/page"'); +}); + +test('blank URL returns empty HTML string', function (): void { + expect(generate_href_html(''))->toHtml()->toBe(''); + expect(generate_href_html(null))->toHtml()->toBe(''); +}); + +test('SPA URL exceptions can be chained', function (): void { + FilamentView::spa(); + FilamentView::spaUrlExceptions(['*/admin/*']); + FilamentView::spaUrlExceptions(['*/api/*']); + + expect(FilamentView::hasSpaMode('http://localhost/admin/users'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/api/data'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/dashboard'))->toBeTrue(); +}); + +test('SPA URL exceptions work with complex patterns', function (): void { + FilamentView::spa(); + FilamentView::spaUrlExceptions([ + '*/admin/users/*', + '*/api/v1/*', + '*/public/*', + ]); + + expect(FilamentView::hasSpaMode('http://localhost/admin/users/123'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/api/v1/users'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/public/files'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/admin/dashboard'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/api/v2/users'))->toBeTrue(); +}); + +test('SPA mode works with different URL formats', function (): void { + FilamentView::spa(); + + $root = request()->root(); + + expect(FilamentView::hasSpaMode($root . '/page'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root . '/page?param=value'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root . '/page#fragment'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root . '/'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root))->toBeTrue(); +}); + +test('SPA prefetching works with different URL formats', function (): void { + FilamentView::spa(true, true); + + $root = request()->root(); + + expect(FilamentView::hasSpaPrefetching($root . '/page'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root . '/page?param=value'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root . '/page#fragment'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root . '/'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root))->toBeTrue(); +}); + +test('SPA mode handles edge cases gracefully', function (): void { + FilamentView::spa(); + + expect(FilamentView::hasSpaMode('http://localhost'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost///'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/page with spaces'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/page%20with%20encoding'))->toBeTrue(); +}); + +test('SPA prefetching handles edge cases gracefully', function (): void { + FilamentView::spa(true, true); + + expect(FilamentView::hasSpaPrefetching('http://localhost'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost/'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost///'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost/page with spaces'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost/page%20with%20encoding'))->toBeTrue(); +});