diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index 1625e9f08..fb24414cd 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -22,6 +22,14 @@ class Index extends Component public int $defaultTake = 10; + public function updatedDefaultTake(): void + { + $this->defaultTake = max(1, min(100, $this->defaultTake)); + + $this->skip = 0; + $this->loadDeployments(); + } + public bool $showNext = false; public bool $showPrev = false; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f5516d2a1..ea8394c1b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -44,6 +44,14 @@ class All extends Component public int $perPage = 10; + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + public bool $is_env_sorting_enabled = false; public bool $use_build_secrets = false; diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index d361f90ca..a03820b4b 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -58,6 +58,15 @@ class VolumeBackups extends Component public int $timeout = 3600; + public int $perPage = 10; + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + + $this->resetPage(); + } + public bool $delete_backup_s3 = false; public Collection $availableS3Storages; @@ -316,7 +325,7 @@ class VolumeBackups extends Component public function render() { - $executions = $this->backup?->executions()->paginate(10); + $executions = $this->backup?->executions()->paginate($this->perPage); return view('livewire.project.shared.storages.volume-backups', [ 'executions' => $executions ?? collect(), diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 5403fa90d..e60902975 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -16,6 +16,8 @@ class AdminView extends Component public string $sort = 'name_asc'; + public int $perPage = 10; + public function mount() { if (! isInstanceAdmin()) { @@ -42,6 +44,13 @@ class AdminView extends Component $this->resetPage(); } + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + + $this->resetPage(); + } + public function submitSearch(): void { if (! isInstanceAdmin()) { @@ -103,7 +112,7 @@ class AdminView extends Component ->when($this->sort === 'email_desc', fn ($query) => $query->orderByDesc('email')) ->when($this->sort === 'name_asc', fn ($query) => $query->orderBy('name')) ->orderBy('id') - ->paginate(10); + ->paginate($this->perPage); return view('livewire.team.admin-view', [ 'users' => $users, diff --git a/resources/css/app.css b/resources/css/app.css index 059044436..ccaf7181e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1939,6 +1939,28 @@ html[data-theme="custom"] textarea:disabled { box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); } +.floating-dropdown-panel { + min-width: 0; +} + +/* Older menus still render inside their page container. On mobile, keep + those panels fully visible until they are migrated to the portaled + dropdown component. Portaled panels carry their own fixed coordinates. */ +@media (max-width: 767px) { + .listbox-panel:not([style*="position: fixed"]) { + position: fixed !important; + top: 50% !important; + right: auto !important; + left: 50% !important; + width: max-content; + max-width: calc(100vw - 1.5rem) !important; + max-height: calc(100dvh - 1.5rem) !important; + box-sizing: border-box; + transform: translate(-50%, -50%) !important; + z-index: 9999 !important; + } +} + .listbox-option { display: flex; align-items: center; diff --git a/resources/views/components/client-pagination.blade.php b/resources/views/components/client-pagination.blade.php new file mode 100644 index 000000000..924ec6e74 --- /dev/null +++ b/resources/views/components/client-pagination.blade.php @@ -0,0 +1,29 @@ +@props([ + 'summary', + 'pageSizeModel', + 'storageKey', + 'options' => [10, 25, 50, 100], + 'previousAction' => 'page = Math.max(1, page - 1)', + 'nextAction' => 'page = Math.min(totalPages, page + 1)', + 'previousDisabled' => 'page === 1', + 'nextDisabled' => 'page >= totalPages', +]) + + diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php index b48b04143..d466f3907 100644 --- a/resources/views/components/dropdown.blade.php +++ b/resources/views/components/dropdown.blade.php @@ -25,13 +25,10 @@ const triggerRect = this.$refs.trigger.getBoundingClientRect(); const panelRect = this.$refs.panel.getBoundingClientRect(); const viewportPadding = 8; - let left = triggerRect.left; - - if ((left + panelRect.width + viewportPadding) > window.innerWidth) { - left = window.innerWidth - panelRect.width - viewportPadding; - } - - left = Math.max(viewportPadding, left); + const left = Math.max( + viewportPadding, + (window.innerWidth - panelRect.width) / 2, + ); let top = triggerRect.bottom + 4; const maxTop = window.innerHeight - panelRect.height - viewportPadding; diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index b61396a0d..a6078397c 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -14,7 +14,7 @@ 'value' => null, // initial value when wire=false 'disabled' => false, 'tooltip' => true, - 'portal' => false, + 'portal' => true, 'preserveValue' => false, ]) @@ -90,19 +90,26 @@ const gap = 4; const edge = 12; const triggerRect = trigger.getBoundingClientRect(); - const panelWidth = Math.max(triggerRect.width, panel.offsetWidth); + const panelWidth = Math.min( + Math.max(triggerRect.width, panel.offsetWidth), + window.innerWidth - (edge * 2), + ); const panelHeight = Math.min(panel.scrollHeight, 256); const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight; const top = fitsBelow ? triggerRect.bottom + gap : Math.max(edge, triggerRect.top - gap - panelHeight); - const left = Math.min( - Math.max(edge, triggerRect.left), - window.innerWidth - panelWidth - edge, - ); + const left = window.innerWidth < 768 + ? (window.innerWidth - panelWidth) / 2 + : Math.min( + Math.max(edge, triggerRect.left), + window.innerWidth - panelWidth - edge, + ); panel.style.top = `${top}px`; panel.style.left = `${left}px`; + panel.style.width = `${panelWidth}px`; + panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`; panel.style.minWidth = `${triggerRect.width}px`; this.positioned = true; } @@ -123,7 +130,7 @@ @if ($portal) - - +
- - +
-
- -
+
Resource type @@ -38,14 +33,13 @@ -
-
+
-
+ +
- -
- -
- - -
-
+
- -
- -
- - -
-
+
-
-

- Showing - - of - -

-
- - - - - -
-
+ @endif
diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php index c684a212e..838bb6da4 100644 --- a/resources/views/livewire/settings/scheduled-jobs.blade.php +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -56,14 +56,11 @@
-
- -
+
Type @@ -71,7 +68,7 @@ @foreach (['all' => 'All types', 'backup' => 'Backups', 'task' => 'Tasks', 'cleanup' => 'Docker cleanup'] as $value => $label) @endforeach -
-
+ -
- -
+ @foreach (['newest' => 'Newest first', 'oldest' => 'Oldest first'] as $value => $label) @endforeach -
-
+
@@ -191,26 +183,11 @@ @endforeach -
- - Showing of - {{ $executions->count() }} - -
- - Page of - - - -
-
+ @endif @@ -260,26 +237,11 @@ @endforeach -
- - Showing of - {{ $managerRuns->count() }} - -
- - Page of - - - -
-
+ @endif @@ -337,27 +299,12 @@ @endforeach -
- - {{ $skipTotalCount }} {{ Str::plural('skipped job', $skipTotalCount) }} - - @if ($skipTotalCount > $skipDefaultTake) -
- - Page {{ $skipCurrentPage }} of {{ ceil($skipTotalCount / $skipDefaultTake) }} - - - -
- @endif -
+ @endif diff --git a/resources/views/livewire/tags/show.blade.php b/resources/views/livewire/tags/show.blade.php index 649a64bb9..1696382c0 100644 --- a/resources/views/livewire/tags/show.blade.php +++ b/resources/views/livewire/tags/show.blade.php @@ -42,8 +42,9 @@
-
- -
+ + -
-
+
@@ -122,29 +121,9 @@
- -
- -
- - -
-
+
- -
- -
- - -
-
+
+ last-action="setPage({{ $users->lastPage() }})"> + + + + @else diff --git a/resources/views/livewire/team/member/index.blade.php b/resources/views/livewire/team/member/index.blade.php index b2ad88710..687f870a3 100644 --- a/resources/views/livewire/team/member/index.blade.php +++ b/resources/views/livewire/team/member/index.blade.php @@ -129,58 +129,11 @@ description="Try a different name, email address, or role." />
-
-

- Showing - - of - -

-
- - - - - -
-
+ @can('manageInvitations', currentTeam()) diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php index b4b503b9e..acbe900cf 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -39,6 +39,29 @@ test('listbox component uses shared trigger label truncation', function () { ->not->toContain('class="truncate" x-text="current"'); }); +test('portaled listboxes center in the mobile viewport', function () { + $html = Blade::render(''); + + expect($html) + ->toContain('window.innerWidth < 768') + ->toContain('(window.innerWidth - panelWidth) / 2') + ->toContain('panel.style.width = `${panelWidth}px`') + ->toContain('panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`') + ->toContain('x-show="open"') + ->not->toContain('x-show="open && positioned"'); +}); + +test('legacy floating panels have a centered mobile fallback', function () { + $css = file_get_contents(resource_path('css/app.css')); + + expect($css) + ->toContain('@media (max-width: 767px)') + ->toContain('.listbox-panel:not([style*="position: fixed"])') + ->toContain('position: fixed !important;') + ->toContain('left: 50% !important;') + ->toContain('transform: translate(-50%, -50%) !important;'); +}); + test('searchable listbox component uses shared trigger label truncation', function () { $html = Blade::render(<<<'BLADE' toContain('table-toolbar') ->toContain('table-search') ->toContain('wire:model.live="search"') + ->toContain('x-teleport="body"') + ->toContain('positionPanel') + ->toContain('position: fixed') + ->toContain('floating-dropdown-panel') + ->toContain('window.innerWidth < 768') + ->toContain('(window.innerWidth - panelWidth) / 2') + ->toContain('panel.style.width = `${panelWidth}px`') + ->toContain('panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`') + ->toContain('x-show="open"') + ->not->toContain('x-show="open && positioned"') ->toContain('aria-multiselectable="true"') ->toContain('Reset filters') ->toContain('wire:click="clearFilters"') @@ -61,3 +72,30 @@ it('uses the standard search control for frontend filtered infrastructure tables ->toContain('clear-when="search"') ->toContain("clear-action=\"search = ''\""); }); + +it('uses the collision aware dropdown on the projects page', function () { + $projects = file_get_contents(resource_path('views/livewire/project/index.blade.php')); + + expect($projects) + ->toContain('not->toContain('x-show="sortOpen"'); +}); + +it('uses collision aware filter and sort dropdowns on the resources page', function () { + $resources = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php')); + + expect(substr_count($resources, 'toBeGreaterThanOrEqual(2) + ->and($resources) + ->not->toContain('x-show="filterOpen"') + ->not->toContain('x-show="sortOpen"'); +}); + +it('does not use legacy floating sort or filter panels', function () { + $views = collect(File::allFiles(resource_path('views'))) + ->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'php') + ->map(fn (SplFileInfo $file): string => $file->getContents()) + ->implode("\n"); + + expect($views) + ->not->toMatch('/x-show="(?:sortOpen|filterOpen|filtersOpen)"/'); +}); diff --git a/tests/Feature/TablePaginationLoadingTest.php b/tests/Feature/TablePaginationLoadingTest.php index 207fd84ab..2b7a8a5fa 100644 --- a/tests/Feature/TablePaginationLoadingTest.php +++ b/tests/Feature/TablePaginationLoadingTest.php @@ -2,6 +2,47 @@ use Illuminate\Support\Facades\Blade; +it('renders the shared page size selector', function () { + $html = Blade::render(''); + + expect($html) + ->toContain('aria-label="Items per page"') + ->toContain("\$wire.set('perPage'") + ->toContain('h-7!') + ->toContain('w-12!') + ->toContain('opacity-0') + ->toContain('selectedPageSize') + ->toContain('border-0') + ->toContain('text-[11px]!') + ->toContain('mb-0!') + ->toContain("localStorage.getItem('tests.page-size')") + ->toContain("localStorage.setItem('tests.page-size'") + ->toContain('value="10"') + ->toContain('value="25"') + ->toContain('value="50"') + ->toContain('value="100"'); + expect($html) + ->toContain('value="custom"') + ->toContain('data-custom-page-size') + ->toContain('customOption?.remove()') + ->not->toContain('