R24: a modal is never taller than the screen
tests / pest (push) Failing after 11m6s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feature/betriebsmodus v1.3.42
nexxo 2026-07-29 23:33:57 +02:00
parent f00cbf18d3
commit 0a89695189
19 changed files with 468 additions and 163 deletions

View File

@ -267,3 +267,48 @@ Identitäten nach R21), ein Benutzerzugang entzogen.
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf `tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen. `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**
(`<x-ui.modal @if(…) wire:poll @endif>`). 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 (`<form`, `<input`, `<textarea`,
`<select`, `x-ui.input`). Eine zweizeilige Rückfrage mit einem Knopf hat nichts,
was festbleiben müsste, und wird nicht umgebaut.
### Erzwungen durch
`tests/Feature/ModalHeightTest.php` — das Panel bleibt begrenzt, jedes Modal mit
einem Feld benutzt `<x-ui.modal>`, und keine Blade-Direktive steht in der
Attributliste eines Komponenten-Tags.

View File

@ -1 +1 @@
1.3.41 1.3.42

View File

@ -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. --}}
<div {{ $attributes->class(['flex min-h-0 flex-1 flex-col']) }}>
@if ($title !== null || isset($header))
<div class="shrink-0 border-b border-line px-6 py-4">
@isset($header)
{{ $header }}
@else
<h2 class="text-lg font-semibold text-ink">{{ $title }}</h2>
@if ($subtitle !== null)
<p class="mt-1 max-w-[60ch] text-sm text-muted">{{ $subtitle }}</p>
@endif
@endisset
</div>
@endif
{{-- The only part that scrolls. overscroll-contain so reaching the end of it
does not start scrolling the page behind the backdrop. --}}
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5">
{{ $slot }}
</div>
@isset($footer)
<div class="shrink-0 border-t border-line bg-surface-2 px-6 py-4">
{{ $footer }}
</div>
@endisset
</div>

View File

@ -1,8 +1,8 @@
<div class="p-6"> {{-- R24: the form scrolls, the title and the buttons do not. This modal is the
<h2 class="text-lg font-semibold text-ink">{{ __('edit_customer.title') }}</h2> reason the rule exists with eight fields it was taller than the window and
<p class="mt-1 max-w-[60ch] text-sm text-muted">{{ __('edit_customer.subtitle') }}</p> its own save button was off the bottom of it. --}}
<x-ui.modal :title="__('edit_customer.title')" :subtitle="__('edit_customer.subtitle')">
<form wire:submit="save" class="mt-5 space-y-4"> <form id="edit-customer" wire:submit="save" class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('edit_customer.name')" /> <x-ui.input name="name" wire:model="name" :label="__('edit_customer.name')" />
<x-ui.input name="contactName" wire:model="contactName" :label="__('edit_customer.contact')" /> <x-ui.input name="contactName" wire:model="contactName" :label="__('edit_customer.contact')" />
@ -60,9 +60,15 @@
{{-- What it actually decides: the language of the mail they get. --}} {{-- What it actually decides: the language of the mail they get. --}}
<p class="mt-1.5 text-xs text-muted">{{ __('edit_customer.locale_hint') }}</p> <p class="mt-1.5 text-xs text-muted">{{ __('edit_customer.locale_hint') }}</p>
</div> </div>
</form>
<div class="flex flex-wrap gap-2 border-t border-line pt-4"> <x-slot:footer>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save"> <div class="flex flex-wrap gap-2">
{{-- form=, because the button now lives outside the <form> it submits:
that is the price of a footer that does not scroll away, and it
is an HTML attribute rather than a workaround. --}}
<x-ui.button type="submit" form="edit-customer" variant="primary"
wire:loading.attr="disabled" wire:target="save">
{{ __('edit_customer.save') }} {{ __('edit_customer.save') }}
</x-ui.button> </x-ui.button>
{{-- Livewire.dispatch, not Alpine's: the modal listens for a Livewire {{-- Livewire.dispatch, not Alpine's: the modal listens for a Livewire
@ -71,5 +77,5 @@
{{ __('edit_customer.cancel') }} {{ __('edit_customer.cancel') }}
</x-ui.button> </x-ui.button>
</div> </div>
</form> </x-slot:footer>
</div> </x-ui.modal>

View File

@ -1,4 +1,4 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('datacenters.edit_title') }}</h3> <h3 class="text-base font-semibold text-ink">{{ __('datacenters.edit_title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $code }}</span> <span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $code }}</span>
@ -63,8 +63,10 @@
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('datacenters.active_hint') }}</p> <p class="mt-1.5 pl-7 text-xs text-muted">{{ __('datacenters.active_hint') }}</p>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('datacenters.save') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('datacenters.save') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,7 +1,5 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal :title="$uuid === '' ? __('finance.targets_add') : __('finance.targets_edit')">
<h3 class="text-base font-semibold text-ink">{{ $uuid === '' ? __('finance.targets_add') : __('finance.targets_edit') }}</h3> <div class="space-y-4">
<div class="mt-4 space-y-4">
<x-ui.input name="name" wire:model="name" :label="__('finance.target.name')" placeholder="Büro-NAS" /> <x-ui.input name="name" wire:model="name" :label="__('finance.target.name')" placeholder="Büro-NAS" />
<div> <div>
@ -49,8 +47,10 @@
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,4 +1,4 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('finance.series_edit_title') }}</h3> <h3 class="text-base font-semibold text-ink">{{ __('finance.series_edit_title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $prefix }}</span> <span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $prefix }}</span>
@ -47,8 +47,10 @@
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,8 +1,6 @@
<div class="p-6"> {{-- R24: the ten-row textarea scrolls, the title and the buttons stay. --}}
<h2 class="text-lg font-semibold text-ink">{{ __('templates.edit_title') }}</h2> <x-ui.modal :title="__('templates.edit_title')" :subtitle="__('templates.edit_body')">
<p class="mt-1 text-sm text-muted">{{ __('templates.edit_body') }}</p> <form id="edit-mail-template" wire:submit="save" class="space-y-4">
<form wire:submit="save" class="mt-5 space-y-4">
<x-ui.input name="name" wire:model="name" :label="__('templates.name')" /> <x-ui.input name="name" wire:model="name" :label="__('templates.name')" />
<x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" /> <x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" />
@ -33,15 +31,21 @@
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2 border-t border-line pt-4"> </form>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
<x-slot:footer>
<div class="flex flex-wrap gap-2">
{{-- form=, because the button lives outside the <form> it submits
the price of a footer that does not scroll away, and an HTML
attribute rather than a workaround. --}}
<x-ui.button type="submit" form="edit-mail-template" variant="primary"
wire:loading.attr="disabled" wire:target="save">
{{ __('templates.save') }} {{ __('templates.save') }}
</x-ui.button> </x-ui.button>
{{-- Livewire.dispatch, not Alpine's $dispatch. Alpine's fires a {{-- Livewire.dispatch, not Alpine's $dispatch. Alpine's fires a
browser DOM event, and the modal listens for a LIVEWIRE event of browser DOM event, and the modal listens for a LIVEWIRE event of
that name so the button did nothing at all. Every other modal that name so the button did nothing at all. --}}
in this repo does it this way; this one was the odd one out. --}}
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('templates.cancel') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('templates.cancel') }}</x-ui.button>
</div> </div>
</form> </x-slot:footer>
</div> </x-ui.modal>

View File

@ -1,4 +1,4 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('plans.marketing_title') }}</h3> <h3 class="text-base font-semibold text-ink">{{ __('plans.marketing_title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $family->name }}</span> <span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $family->name }}</span>
@ -26,8 +26,10 @@
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('plans.recommended_hint') }}</p> <p class="mt-1.5 pl-7 text-xs text-muted">{{ __('plans.recommended_hint') }}</p>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('plans.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('plans.save') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('plans.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('plans.save') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,9 +1,16 @@
<?php use Illuminate\Support\Number; ?> <?php use Illuminate\Support\Number; ?>
<div class="rounded-lg bg-surface p-6"> {{-- R24: the longest modal in the console every plan, every add-on, a note
<div class="flex items-center justify-between"> field. It was well past the bottom of the window and took its own grant
<h3 class="text-base font-semibold text-ink">{{ __('admin.grant.title') }}</h3> button with it. --}}
<span class="rounded bg-surface-2 px-2 py-0.5 text-xs text-muted">{{ $customerName }}</span> <x-ui.modal>
</div> <x-slot:header>
{{-- Its own header: whose account this is belongs beside the title, and
the plain `title` prop cannot say two things. --}}
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-ink">{{ __('admin.grant.title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 text-xs text-muted">{{ $customerName }}</span>
</div>
</x-slot:header>
@if (count($grants) > 0) @if (count($grants) > 0)
<div class="mt-4 rounded-lg border border-line bg-surface-2 p-3"> <div class="mt-4 rounded-lg border border-line bg-surface-2 p-3">
@ -173,10 +180,12 @@
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin.grant.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="grant" wire:loading.attr="disabled"> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin.grant.cancel') }}</x-ui.button>
<x-ui.icon name="tag" class="size-4" />{{ __('admin.grant.submit') }} <x-ui.button variant="primary" wire:click="grant" wire:loading.attr="disabled">
</x-ui.button> <x-ui.icon name="tag" class="size-4" />{{ __('admin.grant.submit') }}
</div> </x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,8 +1,20 @@
<div class="p-6" @if ($waiting && ! $credentials && ! $failed) wire:poll.2s @endif> {{-- R24. --}}
<h3 class="text-base font-semibold text-ink">{{ __('instances.admin_title', ['name' => $subdomain]) }}</h3> <x-ui.modal :title="__('instances.admin_title', ['name' => $subdomain])">
{{-- 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)
<div wire:poll.2s class="hidden" aria-hidden="true"></div>
@endif
@if ($credentials) @if ($credentials)
<p class="mt-1 text-sm text-muted">{{ __('instances.admin_ready') }}</p> <p class="text-sm text-muted">{{ __('instances.admin_ready') }}</p>
<dl class="mt-4 space-y-2 rounded-lg border border-line bg-surface-2 p-4 text-sm"> <dl class="mt-4 space-y-2 rounded-lg border border-line bg-surface-2 p-4 text-sm">
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
@ -21,34 +33,35 @@
</dl> </dl>
<p class="mt-3 text-xs text-muted">{{ __('instances.admin_once') }}</p> <p class="mt-3 text-xs text-muted">{{ __('instances.admin_once') }}</p>
<div class="mt-5 flex justify-end">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
</div>
@elseif ($failed) @elseif ($failed)
<p class="mt-2 rounded-lg border border-danger-border bg-danger-bg p-3 text-sm text-body"> <p class="rounded-lg border border-danger-border bg-danger-bg p-3 text-sm text-body">
{{ __('instances.admin_failed') }} {{ __('instances.admin_failed') }}
</p> </p>
<div class="mt-5 flex justify-end">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
</div>
@elseif ($waiting) @elseif ($waiting)
<div class="mt-6 flex items-center gap-3 text-sm text-muted"> <div class="flex items-center gap-3 text-sm text-muted">
<span class="size-2 animate-pulse rounded-pill bg-accent" aria-hidden="true"></span> <span class="size-2 animate-pulse rounded-pill bg-accent" aria-hidden="true"></span>
{{ __('instances.admin_working') }} {{ __('instances.admin_working') }}
</div> </div>
@else @else
<p class="mt-1 text-sm text-muted">{{ __('instances.admin_body') }}</p> <p class="text-sm text-muted">{{ __('instances.admin_body') }}</p>
<form wire:submit="request" class="mt-4 space-y-3"> <form id="instance-admin-access" wire:submit="request" class="mt-4 space-y-3">
<x-ui.input name="password" type="password" wire:model="password" <x-ui.input name="password" type="password" wire:model="password"
:label="__('instances.your_password')" autocomplete="current-password" /> :label="__('instances.your_password')" autocomplete="current-password" />
<div class="flex justify-end gap-2">
<x-ui.button variant="secondary" type="button" x-on:click="Livewire.dispatch('closeModal')">{{ __('instances.cancel') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
<x-ui.icon name="unlock" class="size-4" />{{ __('instances.admin_confirm') }}
</x-ui.button>
</div>
</form> </form>
@endif @endif
</div>
<x-slot:footer>
<div class="flex justify-end gap-2">
<x-ui.button variant="secondary" type="button" x-on:click="Livewire.dispatch('closeModal')">
{{ $credentials || $failed ? __('common.close') : __('instances.cancel') }}
</x-ui.button>
@if (! $credentials && ! $failed && ! $waiting)
{{-- form=, because the footer sits outside the <form> it submits. --}}
<x-ui.button variant="primary" type="submit" form="instance-admin-access" wire:loading.attr="disabled">
<x-ui.icon name="unlock" class="size-4" />{{ __('instances.admin_confirm') }}
</x-ui.button>
@endif
</div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,23 +1,17 @@
<div class="p-6"> {{-- R24: the configuration itself is long, so it is the part that scrolls
<h3 class="text-base font-semibold text-ink">{{ __('vpn.get_config_title', ['name' => $name]) }}</h3> the title and the close button stay where they were. --}}
<x-ui.modal :title="__('vpn.get_config_title', ['name' => $name])">
@if ($config === null) @if ($config === null)
<p class="mt-1 text-sm text-muted">{{ __('vpn.get_config_body') }}</p> <p class="text-sm text-muted">{{ __('vpn.get_config_body') }}</p>
<form wire:submit="reveal" class="mt-4 space-y-3"> <form id="vpn-config-access" wire:submit="reveal" class="mt-4 space-y-3">
{{-- autocomplete hint keeps password managers from offering to save {{-- autocomplete hint keeps password managers from offering to save
this as a new login for the site. --}} this as a new login for the site. --}}
<x-ui.input name="password" type="password" wire:model="password" <x-ui.input name="password" type="password" wire:model="password"
:label="__('vpn.your_password')" autocomplete="current-password" /> :label="__('vpn.your_password')" autocomplete="current-password" />
<div class="flex justify-end gap-2">
<x-ui.button variant="secondary" type="button" x-on:click="Livewire.dispatch('closeModal')">{{ __('vpn.cancel') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
<x-ui.icon name="unlock" class="size-4" />{{ __('vpn.reveal') }}
</x-ui.button>
</div>
</form> </form>
@else @else
<div class="mt-3" x-data="vpnConfigActions(@js($this->filename()))"> <div x-data="vpnConfigActions(@js($this->filename()))">
<pre x-ref="config" class="max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-4 font-mono text-xs leading-relaxed text-body">{{ $config }}</pre> <pre x-ref="config" class="max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-4 font-mono text-xs leading-relaxed text-body">{{ $config }}</pre>
<div class="mt-3 flex flex-wrap items-center gap-2"> <div class="mt-3 flex flex-wrap items-center gap-2">
<x-ui.button variant="primary" x-on:click="download()"> <x-ui.button variant="primary" x-on:click="download()">
@ -40,9 +34,20 @@
</div> </div>
@endif @endif
<div class="mt-5 flex justify-end">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
</div>
</div> </div>
@endif @endif
</div>
<x-slot:footer>
<div class="flex justify-end gap-2">
<x-ui.button variant="secondary" type="button" x-on:click="Livewire.dispatch('closeModal')">
{{ $config === null ? __('vpn.cancel') : __('common.close') }}
</x-ui.button>
@if ($config === null)
{{-- form=, because the footer sits outside the <form>. --}}
<x-ui.button variant="primary" type="submit" form="vpn-config-access" wire:loading.attr="disabled">
<x-ui.icon name="unlock" class="size-4" />{{ __('vpn.reveal') }}
</x-ui.button>
@endif
</div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,4 +1,4 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning"> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" /> <x-ui.icon name="alert-triangle" class="size-5" />
@ -21,10 +21,12 @@
@error('confirmName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror @error('confirmName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="danger" wire:click="cancelPackage" wire:loading.attr="disabled"> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="cancelPackage" wire:loading.attr="disabled">
{{ __('settings.cancel_confirm') }} {{ __('settings.cancel_confirm') }}
</x-ui.button> </x-ui.button>
</div> </div>
</div> </x-slot:footer>
</x-ui.modal>

View File

@ -1,37 +1,43 @@
<div class="rounded-lg bg-surface p-6"> {{-- R24: the header and the footer are outside the scroll region, and both
@if ($blocked) branches of this modal use the same two slots the alternative is two
modals in one file, which is how the two drift apart. --}}
<x-ui.modal>
<x-slot:header>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning"> <span @class([
'grid size-10 shrink-0 place-items-center rounded-lg',
'bg-warning-bg text-warning' => $blocked,
'bg-danger-bg text-danger' => ! $blocked,
])>
<x-ui.icon name="alert-triangle" class="size-5" /> <x-ui.icon name="alert-triangle" class="size-5" />
</span> </span>
<div class="min-w-0"> <div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('settings.close_blocked_title') }}</h3> <h3 class="text-base font-semibold text-ink">
<p class="mt-1 text-sm text-muted">{{ __('settings.close_blocked') }}</p> {{ $blocked ? __('settings.close_blocked_title') : __('settings.close_title') }}
</h3>
<p class="mt-1 text-sm text-muted">
{{ $blocked ? __('settings.close_blocked') : __('settings.close_body') }}
</p>
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end"> </x-slot:header>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
</div> @if (! $blocked)
@else <div>
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('settings.close_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('settings.close_body') }}</p>
</div>
</div>
<div class="mt-4">
<label class="text-sm text-body">{{ __('settings.close_confirm_label', ['word' => __('settings.close_keyword')]) }}</label> <label class="text-sm text-body">{{ __('settings.close_confirm_label', ['word' => __('settings.close_keyword')]) }}</label>
<input type="text" wire:model="confirmWord" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" /> <input type="text" wire:model="confirmWord" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('confirmWord')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror @error('confirmWord')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div> </div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="closeAccount" wire:loading.attr="disabled">
{{ __('settings.close_confirm') }}
</x-ui.button>
</div>
@endif @endif
</div>
<x-slot:footer>
<div class="flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
@if (! $blocked)
<x-ui.button variant="danger" wire:click="closeAccount" wire:loading.attr="disabled">
{{ __('settings.close_confirm') }}
</x-ui.button>
@endif
</div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,7 +1,5 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal :title="__('mail_settings.edit')">
<h3 class="text-base font-semibold text-ink">{{ __('mail_settings.edit') }}</h3> <div class="space-y-4">
<div class="mt-4 space-y-4">
<x-ui.input name="address" type="email" wire:model="address" :label="__('mail_settings.address')" /> <x-ui.input name="address" type="email" wire:model="address" :label="__('mail_settings.address')" />
<x-ui.input name="displayName" wire:model="displayName" :label="__('mail_settings.display_name')" /> <x-ui.input name="displayName" wire:model="displayName" :label="__('mail_settings.display_name')" />
@ -50,8 +48,10 @@
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('mail_settings.save') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('mail_settings.save') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,12 +1,16 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal>
<div class="flex items-center justify-between gap-3"> {{-- Its own header: the badge belongs beside the title, which the plain
<h3 class="text-base font-semibold text-ink">{{ __('users.edit_title') }}</h3> `title` prop cannot express. --}}
@if ($isOwner) <x-slot:header>
<span class="rounded-pill bg-accent-subtle px-2.5 py-0.5 text-xs font-semibold text-accent-text">{{ __('users.role_owner') }}</span> <div class="flex items-center justify-between gap-3">
@endif <h3 class="text-base font-semibold text-ink">{{ __('users.edit_title') }}</h3>
</div> @if ($isOwner)
<span class="rounded-pill bg-accent-subtle px-2.5 py-0.5 text-xs font-semibold text-accent-text">{{ __('users.role_owner') }}</span>
@endif
</div>
</x-slot:header>
<div class="mt-4 space-y-4"> <div class="space-y-4">
<div> <div>
<label class="text-sm font-medium text-body" for="seat-name">{{ __('users.name_optional') }}</label> <label class="text-sm font-medium text-body" for="seat-name">{{ __('users.name_optional') }}</label>
<input id="seat-name" type="text" wire:model="name" wire:keydown.enter="save" <input id="seat-name" type="text" wire:model="name" wire:keydown.enter="save"
@ -31,8 +35,10 @@
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('users.save') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('users.save') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,8 +1,5 @@
<div class="rounded-lg bg-surface p-6"> <x-ui.modal :title="__('support.new')" :subtitle="__('support.new_hint')">
<h3 class="text-base font-semibold text-ink">{{ __('support.new') }}</h3> <div class="space-y-4">
<p class="mt-1 text-sm text-muted">{{ __('support.new_hint') }}</p>
<div class="mt-5 space-y-4">
<div> <div>
<label class="text-sm font-medium text-body" for="sr-category">{{ __('support.category') }}</label> <label class="text-sm font-medium text-body" for="sr-category">{{ __('support.category') }}</label>
<select id="sr-category" wire:model="category" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body"> <select id="sr-category" wire:model="category" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@ -32,8 +29,10 @@
<p class="rounded-md border border-line bg-surface-2 px-3 py-2 text-xs text-muted">{{ __('support.attached_hint') }}</p> <p class="rounded-md border border-line bg-surface-2 px-3 py-2 text-xs text-muted">{{ __('support.attached_hint') }}</p>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button> <div class="flex justify-end gap-3">
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('support.send') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
</div> <x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('support.send') }}</x-ui.button>
</div> </div>
</x-slot:footer>
</x-ui.modal>

View File

@ -39,13 +39,29 @@
x-transition:leave="ease-in duration-200" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95" x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="relative w-full max-w-lg transform rounded-xl bg-surface text-left shadow-xl transition-all" {{-- R24: never taller than the screen.
The panel used to grow with its content, so a form modal
ran off the bottom of the window and its own save button
with it reachable only by scrolling the page behind the
backdrop. Capped here, once, for every modal: the panel
is a column, and whatever inside it is marked as the body
does the scrolling (see x-ui.modal).
100dvh, not 100vh: on a phone the browser's own chrome is
part of vh, which is exactly the case where the last
centimetre matters. --}}
class="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-lg transform flex-col overflow-hidden rounded-xl bg-surface text-left shadow-xl transition-all sm:max-h-[calc(100dvh-3rem)]"
id="modal-container" id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent" x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true" aria-modal="true"
> >
@forelse($components as $id => $component) @forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}"> {{-- min-h-0 and the column: without them the flex child
refuses to shrink below its content, and capping the
panel achieves nothing. --}}
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}"
class="flex min-h-0 flex-1 flex-col">
@livewire($component['name'], $component['arguments'], key($id)) @livewire($component['name'], $component['arguments'], key($id))
</div> </div>
@empty @empty

View File

@ -0,0 +1,143 @@
<?php
use Illuminate\Support\Facades\File;
/**
* R24 a modal is never taller than the screen.
*
* 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. Reported exactly that way.
*
* Two halves, both checked here: the panel is capped once for every modal in the
* published package view, and any modal carrying a field uses the component that
* keeps its header and footer outside the scroll region.
*/
/**
* Every modal view, by way of its component.
*
* Found through the components rather than by globbing the views, because a view
* is only a modal if a ModalComponent renders it and the file names follow the
* class names, which is what makes the mapping possible at all.
*
* @return array<string, string> path => contents
*/
function modalViews(): array
{
$views = [];
foreach (File::allFiles(app_path('Livewire')) as $file) {
if (! str_contains($file->getContents(), 'extends ModalComponent')) {
continue;
}
$kebab = strtolower((string) preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $file->getFilenameWithoutExtension()));
$folder = str_contains($file->getRelativePath(), 'Admin') ? 'admin/' : '';
$path = resource_path('views/livewire/'.$folder.$kebab.'.blade.php');
if (File::exists($path)) {
$views[$folder.$kebab.'.blade.php'] = File::get($path);
}
}
return $views;
}
it('caps the panel once, for every modal there is', function () {
// In the published package view, so no modal can opt out of it and none has
// to remember to. dvh rather than vh: on a phone the browser's own chrome
// counts towards vh, which is exactly the case where the last centimetre
// decides whether the button is reachable.
$wrapper = File::get(resource_path('views/vendor/wire-elements-modal/modal.blade.php'));
expect($wrapper)->toContain('max-h-[calc(100dvh-')
->toContain('flex-col')
->toContain('overflow-hidden')
// The flex child has to be allowed to shrink, or capping the panel
// achieves nothing at all.
->toContain('min-h-0');
});
it('keeps the header and the footer out of the scroll region', function () {
$modal = File::get(resource_path('views/components/ui/modal.blade.php'));
expect($modal)->toContain('shrink-0')
->toContain('overflow-y-auto')
// min-h-0 on the body is the line that makes the overflow engage.
->toContain('min-h-0 flex-1 overflow-y-auto')
// Reaching the end of the body must not start scrolling the page behind
// the backdrop.
->toContain('overscroll-contain');
});
it('builds every modal with a field through the component', function () {
// The criterion is the field, not the line count: a two-line confirmation
// with one button has nothing that needs to stay put, and converting those
// would be churn. The moment somebody adds an input to one, this fails and
// says so.
$offenders = [];
foreach (modalViews() as $name => $body) {
$hasField = preg_match('/<form|<input|<textarea|<select|x-ui\.(?:input|otp-input|checkbox)/', $body) === 1;
if ($hasField && ! str_contains($body, '<x-ui.modal')) {
$offenders[] = $name;
}
}
expect($offenders)->toBe([]);
});
it('puts no Blade directive in a component tag attribute list', function () {
// It lands in the attribute bag and breaks the view outright — a compiled
// `if … endif` where an attribute belongs. It cost this project two
// debugging rounds already: @disabled on x-ui.button, and a conditional
// wire:poll on x-ui.modal. Such attributes go on an element of their own.
$offenders = [];
foreach (File::allFiles(resource_path('views')) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
// Comments stripped: this repository has three times read its own
// explanation of a mistake as the mistake.
$body = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $file->getContents());
// An opening component tag, up to its closing bracket, containing a
// directive. Deliberately narrow: @class and @style ARE supported in
// that position by Blade itself.
if (preg_match('/<x-[\w.:-]+(?:[^>]*?)\s@(?!class|style)\w+\s*\(/s', $body) === 1) {
$offenders[] = $file->getRelativePathname();
}
}
expect($offenders)->toBe([]);
});
it('submits from the footer through form=, since the button is outside the form', function () {
// The price of a footer that does not scroll away — and an HTML attribute
// rather than a workaround. A submit button in the footer of a modal whose
// form is in the body does nothing at all without it.
$offenders = [];
foreach (modalViews() as $name => $body) {
if (! str_contains($body, '<x-slot:footer>') || ! str_contains($body, '<form')) {
continue;
}
// Only where the footer actually holds a submit.
$footer = (string) strstr($body, '<x-slot:footer>');
if (! str_contains($footer, 'type="submit"')) {
continue;
}
// One needle, not two: Pest's toContain() is variadic, so a second
// argument is another string it must contain — not a message. Passing
// the file name there asserted that the footer contains its own
// filename, which is why this failed on a file that was correct.
expect($footer)->toContain('form="');
}
});