From 079e5ecaabd5aac099ee7a9a5cf087270c918199 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 26 Jun 2023 22:18:14 +0200 Subject: [PATCH 01/47] Improved navigation and scroll handling in Tables (#458) --- app/app/Providers/EventServiceProvider.php | 1 - app/resources/views/table/projects.blade.php | 6 +- app/tests/Browser.php | 15 ++++ app/tests/Browser/Table/ScrollTest.php | 80 +++++++++++++++++++ lib/Components/Link.vue | 2 +- lib/Components/Table.vue | 50 +++++++++++- lib/Splade.js | 35 ++++++-- lib/SpladeApp.vue | 13 ++- resources/views/table/head.blade.php | 4 +- resources/views/table/pagination.blade.php | 20 ++--- .../views/table/simple-pagination.blade.php | 8 +- resources/views/table/table.blade.php | 4 +- src/Components/Table.php | 14 ++++ src/Http/SpladeMiddleware.php | 1 + src/Http/SpladeResponseData.php | 27 ++++--- src/ServiceProvider.php | 1 - src/SpladeCore.php | 10 +++ src/SpladeTable.php | 41 ++++++++++ 18 files changed, 283 insertions(+), 49 deletions(-) create mode 100644 app/tests/Browser/Table/ScrollTest.php diff --git a/app/app/Providers/EventServiceProvider.php b/app/app/Providers/EventServiceProvider.php index ab8b2cf7..474b6c10 100644 --- a/app/app/Providers/EventServiceProvider.php +++ b/app/app/Providers/EventServiceProvider.php @@ -5,7 +5,6 @@ use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Facades\Event; class EventServiceProvider extends ServiceProvider { diff --git a/app/resources/views/table/projects.blade.php b/app/resources/views/table/projects.blade.php index 566da4af..01b38cd0 100644 --- a/app/resources/views/table/projects.blade.php +++ b/app/resources/views/table/projects.blade.php @@ -1,7 +1,7 @@ @extends('layout') @section('content') +

Big header to test scroll options

- - -@endsection \ No newline at end of file + +@endsection diff --git a/app/tests/Browser.php b/app/tests/Browser.php index 52213b9c..12478a13 100644 --- a/app/tests/Browser.php +++ b/app/tests/Browser.php @@ -71,4 +71,19 @@ public function attachToFilepond($path) return false; })->pause(250); } + + /** + * Scroll screen to element at the given selector. + * + * @param string $selector + * @return $this + */ + public function scrollToOffset($top) + { + $this->ensurejQueryIsAvailable(); + + $this->driver->executeScript("jQuery(\"html, body\").animate({scrollTop: {$top}}, 0);"); + + return $this; + } } diff --git a/app/tests/Browser/Table/ScrollTest.php b/app/tests/Browser/Table/ScrollTest.php new file mode 100644 index 00000000..9dcc018b --- /dev/null +++ b/app/tests/Browser/Table/ScrollTest.php @@ -0,0 +1,80 @@ +browse(function (Browser $browser) { + $firstProject = Project::orderBy('name')->first(); + $latestProject = Project::orderByDesc('name')->first(); + + $browser + ->visit('table/relationsAndExports') + ->assertSeeIn('tr:nth-child(1) td:nth-child(2)', $firstProject->name) + ->resize(1920, 540) + ->scrollToOffset(100); + + $scrollY = $browser->script('return window.scrollY'); + + $this->assertTrue($scrollY > 0); + + $browser + ->click('@sort-name') + ->waitForText($latestProject->name) + ->assertSeeIn('tr:nth-child(1) td:nth-child(2)', $latestProject->name) + ->pause(500); + + $this->assertEquals($scrollY, $browser->script('return window.scrollY')); + }); + } + + /** + * @test + */ + public function it_can_change_the_scroll_behaviour_on_pagination() + { + $this->browse(function (Browser $browser) { + $firstProject = Project::orderBy('name')->first(); + + // Scroll to top + $browser->visit('table/relationsAndExports') + ->resize(1920, 540) + ->click('@pagination-2') + ->waitUntilMissingText($firstProject->name) + ->pause(750); + + $this->assertEquals([0], $browser->script('return window.scrollY')); + + // Scroll to head + $browser->visit('table/relationsAndExports?paginationScroll=head') + ->resize(1920, 540) + ->click('@pagination-2') + ->waitUntilMissingText($firstProject->name) + ->pause(750); + + $this->assertEquals([true], $browser->script('return window.scrollY > 0')); + + // Preserve scroll + $browser->visit('table/relationsAndExports?paginationScroll=preserve') + ->resize(1920, 540) + ->scrollTo('@pagination-2') + ->click('@pagination-2') + ->waitUntilMissingText($firstProject->name) + ->pause(750); + + $this->assertEquals([true], $browser->script('return window.scrollY > 300')); + }); + } +} diff --git a/lib/Components/Link.vue b/lib/Components/Link.vue index 76961d81..4ecc5eec 100644 --- a/lib/Components/Link.vue +++ b/lib/Components/Link.vue @@ -1,7 +1,7 @@ diff --git a/lib/Components/Table.vue b/lib/Components/Table.vue index 86552f36..75e26e1d 100644 --- a/lib/Components/Table.vue +++ b/lib/Components/Table.vue @@ -16,6 +16,11 @@ export default { inject: ["stack"], props: { + spladeId: { + type: String, + required: true, + }, + baseUrl: { type: String, required: false, @@ -56,6 +61,12 @@ export default { type: Number, required: false, default: 0 + }, + + paginationScroll: { + type: String, + required: false, + default: "top" } }, @@ -117,7 +128,11 @@ export default { } return selectedItemsCount; - } + }, + + scrollToHeadRememberKey() { + return `spladeTableScrollToHead-${this.spladeId}`; + }, }, created() { @@ -151,9 +166,41 @@ export default { } else { this.visibleColumns = columns; } + + if(Splade.restore(this.scrollToHeadRememberKey)) { + this.$nextTick(() => { + const tableElement = document.querySelector(`div[data-splade-id="${this.spladeId}"]`); + + tableElement.scrollIntoView({ + behavior: "smooth", + block: "start", + inline: "nearest" + }); + }); + } + + Splade.forget(this.scrollToHeadRememberKey); }, methods: { + navigate(url, isPagination) { + const headers = { + "X-Splade-Modal": Splade.stackType(this.stack), + "X-Splade-Modal-Target": this.stack, + "X-Splade-Prevent-View-Transition": true + }; + + if(this.paginationScroll !== "top" || !isPagination) { + headers["X-Splade-Preserve-Scroll"] = true; + } + + Splade.request(url, "GET", {}, headers, false).then(() => { + if(!Splade.isSsr && this.paginationScroll === "head" && isPagination) { + Splade.remember(this.scrollToHeadRememberKey, true); + } + }); + }, + visitLink(url, type, $event) { if($event?.target?.tagName === "A" || $event?.target?.tagName === "BUTTON") { return; @@ -486,6 +533,7 @@ export default { striped: this.striped, toggleColumn: this.toggleColumn, updateQuery: this.updateQuery, + navigate: this.navigate, visit: this.visitLink, totalSelectedItems: this.totalSelectedItems, allItemsFromAllPagesAreSelected: this.allItemsFromAllPagesAreSelected, diff --git a/lib/Splade.js b/lib/Splade.js index fb2a3221..ea05b1ad 100644 --- a/lib/Splade.js +++ b/lib/Splade.js @@ -268,14 +268,15 @@ function newPageFromResponse(response, replace, hash) { let scrollY = 0; - if (!isSsr && replace && response.data.splade.preserveScroll) { + if (!isSsr && response.data.splade.preserveScroll) { scrollY = window.scrollY; } // Bring the HTML to the Vue app. onHtml( preventHtmlRefresh ? currentPage.value.html : html, - scrollY + scrollY, + !replace && !response.data.splade.preventViewTransition ); // Initialize a new page object. @@ -453,8 +454,8 @@ function onHead(head) { /** * Passes the given HTML and scroll position to the configurable 'onHtml' function. */ -function onHtml(html, scrollY) { - onHtmlFunction.value(html, scrollY); +function onHtml(html, scrollY, animate) { + onHtmlFunction.value(html, scrollY, animate === true); } /** @@ -490,11 +491,18 @@ function remember(key, data, useLocalStorage) { } } +/** + * Helper method to retrieve the remembered data from the browser's local storage. + */ +function getSpladeDataFromLocalStorage() { + return JSON.parse(localStorage.getItem("splade") || "{}") || {}; +} + /** * Stores the given data in the browser's local storage. */ function storeInLocalStorage(key, data) { - let allData = JSON.parse(localStorage.getItem("splade") || "{}") || {}; + let allData = getSpladeDataFromLocalStorage(); allData[key] = data; @@ -506,7 +514,7 @@ function storeInLocalStorage(key, data) { */ function restore(key, useLocalStorage) { if (useLocalStorage) { - const spladeData = JSON.parse(localStorage.getItem("splade") || "{}") || {}; + const spladeData = getSpladeDataFromLocalStorage(); return spladeData[key]; } @@ -514,6 +522,20 @@ function restore(key, useLocalStorage) { return rememberedData.value[key]; } +/** + * Removes an item from the remember-object or from the browser's local storage. + */ +function forget(key, useLocalStorage) { + if (useLocalStorage) { + let allData = getSpladeDataFromLocalStorage(); + + delete allData[key]; + + localStorage.setItem("splade", JSON.stringify(allData)); + } + + delete rememberedData.value[key]; +} /** * Performes an ajax request and returns the promise. */ @@ -775,6 +797,7 @@ const Splade = { dismissToast, restore, remember, + forget, popStack, currentStack: stack, // ref stackType: stackType, diff --git a/lib/SpladeApp.vue b/lib/SpladeApp.vue index dd9ce66c..41aeec35 100644 --- a/lib/SpladeApp.vue +++ b/lib/SpladeApp.vue @@ -275,15 +275,14 @@ const onHtml = (newHtml, scrollY) => { }); }; -Splade.setOnHtml((newHtml, scrollY) => { - // Fallback for browsers that don't support this API: - if (Splade.isSsr || !document.startViewTransition || !$spladeOptions.view_transitions) { - onHtml(newHtml, scrollY); - return; +Splade.setOnHtml((newHtml, scrollY, animate) => { + if (!Splade.isSsr && document.startViewTransition && $spladeOptions.view_transitions && animate) { + // With a transition: + return document.startViewTransition(() => onHtml(newHtml, scrollY)); } - // With a transition: - document.startViewTransition(() => onHtml(newHtml, scrollY)); + // SSR, no animation configruation, and fallback for unsupported browsers: + onHtml(newHtml, scrollY, false); }); /** diff --git a/resources/views/table/head.blade.php b/resources/views/table/head.blade.php index 1a938769..cfc230d3 100644 --- a/resources/views/table/head.blade.php +++ b/resources/views/table/head.blade.php @@ -12,7 +12,7 @@ class="@if($loop->first && $hasBulkActions) pr-6 @else px-6 @endif py-3 text-left text-xs font-medium tracking-wide text-gray-500 {{ $column->classes }}" > @if($column->sortable) - + @endif @@ -32,7 +32,7 @@ class="@if($loop->first && $hasBulkActions) pr-6 @else px-6 @endif py-3 text-lef @if($column->sortable) - + @endif @endforeach diff --git a/resources/views/table/pagination.blade.php b/resources/views/table/pagination.blade.php index 8097a8f4..65c6f041 100644 --- a/resources/views/table/pagination.blade.php +++ b/resources/views/table/pagination.blade.php @@ -5,17 +5,17 @@ {!! __('pagination.previous') !!} @else - + {!! __('pagination.previous') !!} - + @endif @includeWhen($hasPerPageOptions ?? true, 'splade::table.per-page-selector') @if ($paginator->hasMorePages()) - + {!! __('pagination.next') !!} - + @else {!! __('pagination.next') !!} @@ -55,11 +55,11 @@ @else - + @endif {{-- Pagination Elements --}} @@ -79,9 +79,9 @@ {{ $page }} @else - + {{ $page }} - + @endif @endforeach @endif @@ -89,11 +89,11 @@ {{-- Next Page Link --}} @if ($paginator->hasMorePages()) - + @else @else - + @endif @includeWhen($hasPerPageOptions ?? true, 'splade::table.per-page-selector') {{-- Next Page Link --}} @if ($paginator->hasMorePages()) - + @else {!! __('pagination.next') !!} diff --git a/resources/views/table/table.blade.php b/resources/views/table/table.blade.php index ba299653..554fe4d0 100644 --- a/resources/views/table/table.blade.php +++ b/resources/views/table/table.blade.php @@ -6,9 +6,11 @@ :items-on-this-page="@js($table->totalOnThisPage())" :items-on-all-pages="@js($table->totalOnAllPages())" :base-url="@js(request()->url())" + :pagination-scroll="@js($paginationScroll)" + :splade-id="@js($spladeId = $table->getSpladeId())" >