CluPilotCloud/tests/Feature/ModalHeightTest.php

144 lines
5.6 KiB
PHP

<?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="');
}
});