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 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
parent
f00cbf18d3
commit
0a89695189
45
CLAUDE.md
45
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**
|
||||
(`<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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('edit_customer.title') }}</h2>
|
||||
<p class="mt-1 max-w-[60ch] text-sm text-muted">{{ __('edit_customer.subtitle') }}</p>
|
||||
|
||||
<form wire:submit="save" class="mt-5 space-y-4">
|
||||
{{-- 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. --}}
|
||||
<x-ui.modal :title="__('edit_customer.title')" :subtitle="__('edit_customer.subtitle')">
|
||||
<form id="edit-customer" wire:submit="save" class="space-y-4">
|
||||
<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="contactName" wire:model="contactName" :label="__('edit_customer.contact')" />
|
||||
|
|
@ -60,9 +60,15 @@
|
|||
{{-- 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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex flex-wrap gap-2 border-t border-line pt-4">
|
||||
<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 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') }}
|
||||
</x-ui.button>
|
||||
{{-- Livewire.dispatch, not Alpine's: the modal listens for a Livewire
|
||||
|
|
@ -71,5 +77,5 @@
|
|||
{{ __('edit_customer.cancel') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-slot:footer>
|
||||
</x-ui.modal>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<x-ui.modal>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
|
@ -63,8 +63,10 @@
|
|||
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('datacenters.active_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<h3 class="text-base font-semibold text-ink">{{ $uuid === '' ? __('finance.targets_add') : __('finance.targets_edit') }}</h3>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<x-ui.modal :title="$uuid === '' ? __('finance.targets_add') : __('finance.targets_edit')">
|
||||
<div class="space-y-4">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('finance.target.name')" placeholder="Büro-NAS" />
|
||||
|
||||
<div>
|
||||
|
|
@ -49,8 +47,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<x-ui.modal>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
|
@ -47,8 +47,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('templates.edit_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('templates.edit_body') }}</p>
|
||||
|
||||
<form wire:submit="save" class="mt-5 space-y-4">
|
||||
{{-- R24: the ten-row textarea scrolls, the title and the buttons stay. --}}
|
||||
<x-ui.modal :title="__('templates.edit_title')" :subtitle="__('templates.edit_body')">
|
||||
<form id="edit-mail-template" wire:submit="save" class="space-y-4">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('templates.name')" />
|
||||
<x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" />
|
||||
|
||||
|
|
@ -33,15 +31,21 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 border-t border-line pt-4">
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
|
||||
</form>
|
||||
|
||||
<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') }}
|
||||
</x-ui.button>
|
||||
{{-- 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. --}}
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('templates.cancel') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-slot:footer>
|
||||
</x-ui.modal>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<x-ui.modal>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
|
@ -26,8 +26,10 @@
|
|||
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('plans.recommended_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('plans.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
<?php use Illuminate\Support\Number; ?>
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
{{-- 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. --}}
|
||||
<x-ui.modal>
|
||||
<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)
|
||||
<div class="mt-4 rounded-lg border border-line bg-surface-2 p-3">
|
||||
|
|
@ -173,10 +180,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin.grant.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="grant" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="tag" class="size-4" />{{ __('admin.grant.submit') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:footer>
|
||||
</x-ui.modal>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
<div class="p-6" @if ($waiting && ! $credentials && ! $failed) wire:poll.2s @endif>
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('instances.admin_title', ['name' => $subdomain]) }}</h3>
|
||||
{{-- R24. --}}
|
||||
<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)
|
||||
<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">
|
||||
<div class="flex justify-between gap-4">
|
||||
|
|
@ -21,34 +33,35 @@
|
|||
</dl>
|
||||
|
||||
<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)
|
||||
<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') }}
|
||||
</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)
|
||||
<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>
|
||||
{{ __('instances.admin_working') }}
|
||||
</div>
|
||||
@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"
|
||||
: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>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,17 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('vpn.get_config_title', ['name' => $name]) }}</h3>
|
||||
|
||||
{{-- R24: the configuration itself is long, so it is the part that scrolls —
|
||||
the title and the close button stay where they were. --}}
|
||||
<x-ui.modal :title="__('vpn.get_config_title', ['name' => $name])">
|
||||
@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
|
||||
this as a new login for the site. --}}
|
||||
<x-ui.input name="password" type="password" wire:model="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>
|
||||
@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>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<x-ui.button variant="primary" x-on:click="download()">
|
||||
|
|
@ -40,9 +34,20 @@
|
|||
</div>
|
||||
@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>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<x-ui.modal>
|
||||
<div class="flex items-start gap-3">
|
||||
<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" />
|
||||
|
|
@ -21,10 +21,12 @@
|
|||
@error('confirmName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<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>
|
||||
<x-ui.button variant="danger" wire:click="cancelPackage" wire:loading.attr="disabled">
|
||||
{{ __('settings.cancel_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:footer>
|
||||
</x-ui.modal>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,43 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
@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. --}}
|
||||
<x-ui.modal>
|
||||
<x-slot:header>
|
||||
<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" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('settings.close_blocked_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('settings.close_blocked') }}</p>
|
||||
<h3 class="text-base font-semibold text-ink">
|
||||
{{ $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 class="mt-6 flex justify-end">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
|
||||
</div>
|
||||
@else
|
||||
<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">
|
||||
</x-slot:header>
|
||||
|
||||
@if (! $blocked)
|
||||
<div>
|
||||
<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" />
|
||||
@error('confirmWord')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
@endif
|
||||
|
||||
<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>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot:footer>
|
||||
</x-ui.modal>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('mail_settings.edit') }}</h3>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<x-ui.modal :title="__('mail_settings.edit')">
|
||||
<div class="space-y-4">
|
||||
<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')" />
|
||||
|
|
@ -50,8 +48,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<x-ui.modal>
|
||||
{{-- Its own header: the badge belongs beside the title, which the plain
|
||||
`title` prop cannot express. --}}
|
||||
<x-slot:header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('users.edit_title') }}</h3>
|
||||
@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>
|
||||
<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"
|
||||
|
|
@ -31,8 +35,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('support.new') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('support.new_hint') }}</p>
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
<x-ui.modal :title="__('support.new')" :subtitle="__('support.new_hint')">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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">
|
||||
|
|
@ -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>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -39,13 +39,29 @@
|
|||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
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"
|
||||
x-trap.noscroll.inert="show && showActiveComponent"
|
||||
aria-modal="true"
|
||||
>
|
||||
@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))
|
||||
</div>
|
||||
@empty
|
||||
|
|
|
|||
|
|
@ -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="');
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue