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('/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('/]*?)\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, '') || ! str_contains($body, ''); 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="'); } });