144 lines
5.6 KiB
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="');
|
|
}
|
|
});
|