From 0a89695189a770641e959a3495ada636db36bb8e Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 23:33:57 +0200 Subject: [PATCH] R24: a modal is never taller than the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modal grew with its content, so a form ran off the bottom of the window and took its own save button with it — reachable only by scrolling the page BEHIND the backdrop, which on a phone means not reachable at all. The customer form with its eight fields is what made it obvious, but it was true of every long modal in the console. Two halves. The panel is capped once, in the published package view, so no modal can opt out of it and none has to remember to: max-h in dvh rather than vh, because on a phone the browser's own chrome counts towards vh and that is exactly the case where the last centimetre decides whether the button can be reached. And x-ui.modal is the other half — a header slot, a scrolling body, a footer slot. min-h-0 on the body is the line that makes it work: a flex child will not shrink below its content without it, and the overflow never engages. Fourteen modals converted: the ones that carry a field, which are the ones that can grow. A two-line confirmation with one button has nothing that needs to stay put, and the rule says so rather than leaving a footnote — the test's criterion is the field, so the moment somebody adds an input to a confirmation it fails and tells them. A submit button in a footer sits outside the form it submits, so it carries form="…". That is the price of a footer that does not scroll away, and an HTML attribute rather than a workaround; there is a test for it, because without it the button silently does nothing. Two traps met on the way, both now scanned for. A Blade directive in a component tag's attribute list (a conditional wire:poll on x-ui.modal) compiles into the attribute bag and breaks the view outright — the same mistake as @disabled on x-ui.button, which cost a debugging round earlier this session; such attributes go on an element of their own. And Pest's toContain() is variadic, so the second argument I passed as a failure message was read as another needle: the assertion demanded that a footer contain its own filename, and failed on a file that was correct. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 45 ++++++ VERSION | 2 +- resources/views/components/ui/modal.blade.php | 45 ++++++ .../livewire/admin/edit-customer.blade.php | 24 +-- .../livewire/admin/edit-datacenter.blade.php | 14 +- .../admin/edit-export-target.blade.php | 18 +-- .../admin/edit-invoice-series.blade.php | 14 +- .../admin/edit-mail-template.blade.php | 26 ++-- .../livewire/admin/edit-plan-family.blade.php | 14 +- .../views/livewire/admin/grant-plan.blade.php | 33 ++-- .../admin/instance-admin-access.blade.php | 55 ++++--- .../admin/vpn-config-access.blade.php | 37 +++-- .../livewire/confirm-cancel-package.blade.php | 14 +- .../livewire/confirm-close-account.blade.php | 58 +++---- .../views/livewire/edit-mailbox.blade.php | 18 +-- resources/views/livewire/edit-seat.blade.php | 32 ++-- .../livewire/new-support-request.blade.php | 19 ++- .../wire-elements-modal/modal.blade.php | 20 ++- tests/Feature/ModalHeightTest.php | 143 ++++++++++++++++++ 19 files changed, 468 insertions(+), 163 deletions(-) create mode 100644 resources/views/components/ui/modal.blade.php create mode 100644 tests/Feature/ModalHeightTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 9be1550..6b7bda2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -267,3 +267,48 @@ Identitäten nach R21), ein Benutzerzugang entzogen. `tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf `wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen. + +--- + +## R24 — Ein Modal wird nie höher als der Bildschirm + +**Kopf und Fuß stehen fest, gescrollt wird nur die Mitte.** + +Verboten: + +1. **Ein Modal, das mit seinem Inhalt wächst.** Es läuft unten aus dem Fenster + heraus und nimmt seinen eigenen Speichern-Knopf mit — erreichbar nur, indem + man die Seite *hinter* dem Overlay scrollt, auf dem Telefon also gar nicht. +2. **Titel oder Knopfzeile im Scrollbereich.** Was man lesen muss, um zu wissen, + wo man ist, und was man erreichen muss, um fertig zu werden, gehören beide + außerhalb. +3. **Eine Blade-Direktive in der Attributliste eines Komponenten-Tags** + (``). Sie landet im Attribut-Beutel und + zerlegt die View — dieselbe Falle wie `@disabled` an einer Komponente. Solche + Attribute gehören auf ein eigenes Element im Inhalt. + +### Wie es gebaut ist + +- `resources/views/vendor/wire-elements-modal/modal.blade.php` begrenzt das + Panel **einmal für alle Modals**: `max-h-[calc(100dvh-3rem)]`, Spalte, + `overflow-hidden`. `dvh` statt `vh`, weil am Telefon die Browserleiste zu `vh` + zählt — genau der Fall, in dem der letzte Zentimeter zählt. +- `resources/views/components/ui/modal.blade.php` ist die andere Hälfte: + `header`-Slot (oder `title`/`subtitle`), scrollender Rumpf, `footer`-Slot. + `min-h-0` am Rumpf ist die Zeile, die es wirken lässt — ein Flex-Kind wird + ohne sie nicht kleiner als sein Inhalt, und `overflow` greift nie. +- Ein Knopf im Fuß, der ein Formular im Rumpf abschickt, benutzt + `type="submit" form="…"`. Das ist der Preis für einen Fuß, der nicht + wegscrollt — und ein HTML-Attribut, kein Trick. + +### Gilt für + +Jedes Modal, in dem ein **Eingabefeld** steckt (``, und keine Blade-Direktive steht in der +Attributliste eines Komponenten-Tags. diff --git a/VERSION b/VERSION index 6972043..3eba9bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.41 +1.3.42 diff --git a/resources/views/components/ui/modal.blade.php b/resources/views/components/ui/modal.blade.php new file mode 100644 index 0000000..ab383e3 --- /dev/null +++ b/resources/views/components/ui/modal.blade.php @@ -0,0 +1,45 @@ +@props([ + // The heading, and the sentence under it. Both optional: a modal that is one + // question does not need a subtitle, and a couple have their own header + // markup (a state badge beside the title, an icon). + 'title' => null, + 'subtitle' => null, +]) + +{{-- R24: header and footer stay put, the middle scrolls. + + A modal grew with its content, so a form ran off the bottom of the window + and took its own save button with it — reachable only by scrolling the page + behind the backdrop, which on a phone means not reachable. The panel is + capped in resources/views/vendor/wire-elements-modal/modal.blade.php; this + is the other half: the part somebody has to read (what am I looking at) and + the part they have to reach (the button) are outside the scroll region. + + min-h-0 on the body is the line that makes it work: a flex child refuses to + shrink below its content without it, and the overflow never engages. --}} +
class(['flex min-h-0 flex-1 flex-col']) }}> + @if ($title !== null || isset($header)) +
+ @isset($header) + {{ $header }} + @else +

{{ $title }}

+ @if ($subtitle !== null) +

{{ $subtitle }}

+ @endif + @endisset +
+ @endif + + {{-- The only part that scrolls. overscroll-contain so reaching the end of it + does not start scrolling the page behind the backdrop. --}} +
+ {{ $slot }} +
+ + @isset($footer) +
+ {{ $footer }} +
+ @endisset +
diff --git a/resources/views/livewire/admin/edit-customer.blade.php b/resources/views/livewire/admin/edit-customer.blade.php index c72f86b..433b065 100644 --- a/resources/views/livewire/admin/edit-customer.blade.php +++ b/resources/views/livewire/admin/edit-customer.blade.php @@ -1,8 +1,8 @@ -
-

{{ __('edit_customer.title') }}

-

{{ __('edit_customer.subtitle') }}

- -
+{{-- R24: the form scrolls, the title and the buttons do not. This modal is the + reason the rule exists — with eight fields it was taller than the window and + its own save button was off the bottom of it. --}} + +
@@ -60,9 +60,15 @@ {{-- What it actually decides: the language of the mail they get. --}}

{{ __('edit_customer.locale_hint') }}

+ -
- + +
+ {{-- form=, because the button now lives outside the
it submits: + that is the price of a footer that does not scroll away, and it + is an HTML attribute rather than a workaround. --}} + {{ __('edit_customer.save') }} {{-- Livewire.dispatch, not Alpine's: the modal listens for a Livewire @@ -71,5 +77,5 @@ {{ __('edit_customer.cancel') }}
- -
+ +
diff --git a/resources/views/livewire/admin/edit-datacenter.blade.php b/resources/views/livewire/admin/edit-datacenter.blade.php index 78261ec..a785083 100644 --- a/resources/views/livewire/admin/edit-datacenter.blade.php +++ b/resources/views/livewire/admin/edit-datacenter.blade.php @@ -1,4 +1,4 @@ -
+

{{ __('datacenters.edit_title') }}

{{ $code }} @@ -63,8 +63,10 @@

{{ __('datacenters.active_hint') }}

-
- {{ __('datacenters.cancel') }} - {{ __('datacenters.save') }} -
-
+ +
+ {{ __('datacenters.cancel') }} + {{ __('datacenters.save') }} +
+
+ diff --git a/resources/views/livewire/admin/edit-export-target.blade.php b/resources/views/livewire/admin/edit-export-target.blade.php index f0c0809..ce55631 100644 --- a/resources/views/livewire/admin/edit-export-target.blade.php +++ b/resources/views/livewire/admin/edit-export-target.blade.php @@ -1,7 +1,5 @@ -
-

{{ $uuid === '' ? __('finance.targets_add') : __('finance.targets_edit') }}

- -
+ +
@@ -49,8 +47,10 @@
-
- {{ __('finance.cancel') }} - {{ __('finance.save') }} -
-
+ +
+ {{ __('finance.cancel') }} + {{ __('finance.save') }} +
+
+ diff --git a/resources/views/livewire/admin/edit-invoice-series.blade.php b/resources/views/livewire/admin/edit-invoice-series.blade.php index 02ba45c..6195295 100644 --- a/resources/views/livewire/admin/edit-invoice-series.blade.php +++ b/resources/views/livewire/admin/edit-invoice-series.blade.php @@ -1,4 +1,4 @@ -
+

{{ __('finance.series_edit_title') }}

{{ $prefix }} @@ -47,8 +47,10 @@
-
- {{ __('finance.cancel') }} - {{ __('finance.save') }} -
-
+ +
+ {{ __('finance.cancel') }} + {{ __('finance.save') }} +
+
+ diff --git a/resources/views/livewire/admin/edit-mail-template.blade.php b/resources/views/livewire/admin/edit-mail-template.blade.php index 2300876..b9f3470 100644 --- a/resources/views/livewire/admin/edit-mail-template.blade.php +++ b/resources/views/livewire/admin/edit-mail-template.blade.php @@ -1,8 +1,6 @@ -
-

{{ __('templates.edit_title') }}

-

{{ __('templates.edit_body') }}

- -
+{{-- R24: the ten-row textarea scrolls, the title and the buttons stay. --}} + + @@ -33,15 +31,21 @@

-
- + + + +
+ {{-- form=, because the button lives outside the
it submits — + the price of a footer that does not scroll away, and an HTML + attribute rather than a workaround. --}} + {{ __('templates.save') }} {{-- Livewire.dispatch, not Alpine's $dispatch. Alpine's fires a browser DOM event, and the modal listens for a LIVEWIRE event of - that name — so the button did nothing at all. Every other modal - in this repo does it this way; this one was the odd one out. --}} + that name — so the button did nothing at all. --}} {{ __('templates.cancel') }}
- -
+ + diff --git a/resources/views/livewire/admin/edit-plan-family.blade.php b/resources/views/livewire/admin/edit-plan-family.blade.php index 5aacb83..7284e2e 100644 --- a/resources/views/livewire/admin/edit-plan-family.blade.php +++ b/resources/views/livewire/admin/edit-plan-family.blade.php @@ -1,4 +1,4 @@ -
+

{{ __('plans.marketing_title') }}

{{ $family->name }} @@ -26,8 +26,10 @@

{{ __('plans.recommended_hint') }}

-
- {{ __('plans.cancel') }} - {{ __('plans.save') }} -
-
+ +
+ {{ __('plans.cancel') }} + {{ __('plans.save') }} +
+
+ diff --git a/resources/views/livewire/admin/grant-plan.blade.php b/resources/views/livewire/admin/grant-plan.blade.php index 2ac0bd0..1baf21c 100644 --- a/resources/views/livewire/admin/grant-plan.blade.php +++ b/resources/views/livewire/admin/grant-plan.blade.php @@ -1,9 +1,16 @@ -
-
-

{{ __('admin.grant.title') }}

- {{ $customerName }} -
+{{-- R24: the longest modal in the console — every plan, every add-on, a note + field. It was well past the bottom of the window and took its own grant + button with it. --}} + + + {{-- Its own header: whose account this is belongs beside the title, and + the plain `title` prop cannot say two things. --}} +
+

{{ __('admin.grant.title') }}

+ {{ $customerName }} +
+
@if (count($grants) > 0)
@@ -173,10 +180,12 @@
-
- {{ __('admin.grant.cancel') }} - - {{ __('admin.grant.submit') }} - -
-
+ +
+ {{ __('admin.grant.cancel') }} + + {{ __('admin.grant.submit') }} + +
+
+
diff --git a/resources/views/livewire/admin/instance-admin-access.blade.php b/resources/views/livewire/admin/instance-admin-access.blade.php index 51ffbb2..a569376 100644 --- a/resources/views/livewire/admin/instance-admin-access.blade.php +++ b/resources/views/livewire/admin/instance-admin-access.blade.php @@ -1,8 +1,20 @@ -
-

{{ __('instances.admin_title', ['name' => $subdomain]) }}

+{{-- R24. --}} + + {{-- The poll on an element of its own, not on the component tag: a Blade + directive inside a component's attribute list compiles into the + attribute bag and breaks the view outright — the same trap as @disabled + on a component. Livewire polls the whole component whichever element + carries it, so this does the same job from inside the body. + + Only while there is something to wait for: a poll that keeps running + after the credentials are on screen is a request every two seconds for + nothing. --}} + @if ($waiting && ! $credentials && ! $failed) + + @endif @if ($credentials) -

{{ __('instances.admin_ready') }}

+

{{ __('instances.admin_ready') }}

@@ -21,34 +33,35 @@

{{ __('instances.admin_once') }}

- -
- {{ __('common.close') }} -
@elseif ($failed) -

+

{{ __('instances.admin_failed') }}

-
- {{ __('common.close') }} -
@elseif ($waiting) -
+
{{ __('instances.admin_working') }}
@else -

{{ __('instances.admin_body') }}

+

{{ __('instances.admin_body') }}

-
+ -
- {{ __('instances.cancel') }} - - {{ __('instances.admin_confirm') }} - -
@endif -
+ + +
+ + {{ $credentials || $failed ? __('common.close') : __('instances.cancel') }} + + @if (! $credentials && ! $failed && ! $waiting) + {{-- form=, because the footer sits outside the
it submits. --}} + + {{ __('instances.admin_confirm') }} + + @endif +
+
+
diff --git a/resources/views/livewire/admin/vpn-config-access.blade.php b/resources/views/livewire/admin/vpn-config-access.blade.php index 87ca32c..9fff13c 100644 --- a/resources/views/livewire/admin/vpn-config-access.blade.php +++ b/resources/views/livewire/admin/vpn-config-access.blade.php @@ -1,23 +1,17 @@ -
-

{{ __('vpn.get_config_title', ['name' => $name]) }}

- +{{-- R24: the configuration itself is long, so it is the part that scrolls — + the title and the close button stay where they were. --}} + @if ($config === null) -

{{ __('vpn.get_config_body') }}

+

{{ __('vpn.get_config_body') }}

- + {{-- autocomplete hint keeps password managers from offering to save this as a new login for the site. --}} -
- {{ __('vpn.cancel') }} - - {{ __('vpn.reveal') }} - -
@else -
+
{{ $config }}
@@ -40,9 +34,20 @@
@endif -
- {{ __('common.close') }} -
@endif -
+ + +
+ + {{ $config === null ? __('vpn.cancel') : __('common.close') }} + + @if ($config === null) + {{-- form=, because the footer sits outside the
. --}} + + {{ __('vpn.reveal') }} + + @endif +
+
+
diff --git a/resources/views/livewire/confirm-cancel-package.blade.php b/resources/views/livewire/confirm-cancel-package.blade.php index 841ff0f..4aac027 100644 --- a/resources/views/livewire/confirm-cancel-package.blade.php +++ b/resources/views/livewire/confirm-cancel-package.blade.php @@ -1,4 +1,4 @@ -
+
@@ -21,10 +21,12 @@ @error('confirmName')

{{ $message }}

@enderror
-
- {{ __('settings.keep') }} - + +
+ {{ __('settings.keep') }} + {{ __('settings.cancel_confirm') }} -
-
+
+ + diff --git a/resources/views/livewire/confirm-close-account.blade.php b/resources/views/livewire/confirm-close-account.blade.php index dec22d8..9c045c1 100644 --- a/resources/views/livewire/confirm-close-account.blade.php +++ b/resources/views/livewire/confirm-close-account.blade.php @@ -1,37 +1,43 @@ -
- @if ($blocked) +{{-- R24: the header and the footer are outside the scroll region, and both + branches of this modal use the same two slots — the alternative is two + modals in one file, which is how the two drift apart. --}} + +
- + $blocked, + 'bg-danger-bg text-danger' => ! $blocked, + ])>
-

{{ __('settings.close_blocked_title') }}

-

{{ __('settings.close_blocked') }}

+

+ {{ $blocked ? __('settings.close_blocked_title') : __('settings.close_title') }} +

+

+ {{ $blocked ? __('settings.close_blocked') : __('settings.close_body') }} +

-
- {{ __('settings.keep') }} -
- @else -
- - - -
-

{{ __('settings.close_title') }}

-

{{ __('settings.close_body') }}

-
-
-
+ + + @if (! $blocked) +
@error('confirmWord')

{{ $message }}

@enderror
-
- {{ __('settings.keep') }} - - {{ __('settings.close_confirm') }} - -
@endif -
+ + +
+ {{ __('settings.keep') }} + @if (! $blocked) + + {{ __('settings.close_confirm') }} + + @endif +
+
+
diff --git a/resources/views/livewire/edit-mailbox.blade.php b/resources/views/livewire/edit-mailbox.blade.php index 9ce4bb0..39ec75e 100644 --- a/resources/views/livewire/edit-mailbox.blade.php +++ b/resources/views/livewire/edit-mailbox.blade.php @@ -1,7 +1,5 @@ -
-

{{ __('mail_settings.edit') }}

- -
+ +
@@ -50,8 +48,10 @@
-
- {{ __('common.cancel') }} - {{ __('mail_settings.save') }} -
-
+ +
+ {{ __('common.cancel') }} + {{ __('mail_settings.save') }} +
+
+ diff --git a/resources/views/livewire/edit-seat.blade.php b/resources/views/livewire/edit-seat.blade.php index 19c4cac..c18b8f5 100644 --- a/resources/views/livewire/edit-seat.blade.php +++ b/resources/views/livewire/edit-seat.blade.php @@ -1,12 +1,16 @@ -
-
-

{{ __('users.edit_title') }}

- @if ($isOwner) - {{ __('users.role_owner') }} - @endif -
+ + {{-- Its own header: the badge belongs beside the title, which the plain + `title` prop cannot express. --}} + +
+

{{ __('users.edit_title') }}

+ @if ($isOwner) + {{ __('users.role_owner') }} + @endif +
+
-
+
-
- {{ __('common.cancel') }} - {{ __('users.save') }} -
-
+ +
+ {{ __('common.cancel') }} + {{ __('users.save') }} +
+
+ diff --git a/resources/views/livewire/new-support-request.blade.php b/resources/views/livewire/new-support-request.blade.php index 22fdd0d..3c3b213 100644 --- a/resources/views/livewire/new-support-request.blade.php +++ b/resources/views/livewire/new-support-request.blade.php @@ -1,8 +1,5 @@ -
-

{{ __('support.new') }}

-

{{ __('support.new_hint') }}

- -
+ +