diff --git a/README.md b/README.md index 5c8d4783..bc4ca602 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos ## Showcase -While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` renders every token and component, in every variant, in the application's own scheme and theme. +While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell). ## Testing the design diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index 6ba7d19a..f6273b00 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -589,12 +589,17 @@ pages and the mail theme by an agent in its own worktree. What changed from the `__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and compiled views keep that name. - **A test checks that every component appears in the showcase.** +- **The showcase is the package's own app shell**: an overview at `/material` and a page per section + (`/material/{section}`, `Showcase\Sections`), grouped in the navigation rail, moved between with + wire:navigate. It turned up a WebKit bug in ``: inside a focusable region (the shell's + `
`), WebKit hands focus back to that region as the popover closes, so the + menu now reads whether focus was inside on `beforetoggle` before returning it to the trigger. - **Verified in a fresh Laravel 13.31 application** (`composer create-project`, the package from a path repository, the README's CSS, JS and layout, `material:scheme "#4f46e5"`, `npm run build`): a Livewire page with an app bar, tabs, a card, a form with validation, a date picker, a dialog and a toast works without console errors; `/material` and the 404 page render in its scheme. -- **Known gap:** `` has no clear button (`` does); a date can be - emptied through its text input. +- **``** empties a date, or both ends of a range, as `` + does. ### Phase 11 — SealShare 2.0.0 diff --git a/resources/js/menu.js b/resources/js/menu.js index 993ea64f..345a990d 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => { window.Alpine.data('materialMenu', () => ({ closedAt: -Infinity, returnFocus: true, + focusWasInside: false, listeners: [], init() { @@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => { // and close() have already done their part, synchronously, because this event is // queued and a screen reader or a test reading aria-expanded in between would be told // the menu is shut. + // Whether focus was in the menu is read before it closes: once closed, a browser may + // already have handed focus to what had it before the menu opened (WebKit does, when + // that was a focusable region around the trigger). + this.listen(menu, 'beforetoggle', (event) => { + this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement) + }) + this.listen(menu, 'toggle', (event) => { const opened = event.newState === 'open' @@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => { this.closedAt = performance.now() - if (this.returnFocus && menu.contains(document.activeElement)) { + if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) { this.control()?.focus() } + + this.focusWasInside = false }) // A press outside closes the menu without pulling focus back to the trigger. diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index c4bea848..36205e28 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -1,31 +1,35 @@ @extends('livewire-material::showcase.layout') @section('content') -
-

- Every token and component, in this application's own scheme. -

+
+
+

Material 3 Expressive for Laravel and Livewire.

+

+ Every token and component, rendered in this application's own scheme and theme. Pick a section in the + navigation, or start below. +

+
- @include('livewire-material::showcase.sections.colour') - @include('livewire-material::showcase.sections.type') - @include('livewire-material::showcase.sections.shape') - @include('livewire-material::showcase.sections.elevation') - @include('livewire-material::showcase.sections.motion') - @include('livewire-material::showcase.sections.icons') - @include('livewire-material::showcase.sections.buttons') - @include('livewire-material::showcase.sections.menus') - @include('livewire-material::showcase.sections.communication') - @include('livewire-material::showcase.sections.progress') - @include('livewire-material::showcase.sections.containment') - @include('livewire-material::showcase.sections.carousel') - @include('livewire-material::showcase.sections.fields') - @include('livewire-material::showcase.sections.chips') - @include('livewire-material::showcase.sections.sliders') - @include('livewire-material::showcase.sections.pickers') - @include('livewire-material::showcase.sections.timepickers') - @include('livewire-material::showcase.sections.bars') - @include('livewire-material::showcase.sections.navigation') - @include('livewire-material::showcase.sections.data') - @include('livewire-material::showcase.sections.pages') -
+ @foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries) +
+

{{ $group }}

+ +
+ @foreach ($entries as $key => $entry) + +
+ + + +
+ {{ $entry['title'] }} +

{{ $entry['description'] }}

+
+
+
+ @endforeach +
+
+ @endforeach + @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 93e73bd7..f8414a7c 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -1,46 +1,67 @@ +{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section, + collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone. + Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}} + +@php + $sections ??= \NoNameWeb\LivewireMaterial\Showcase\Sections::all(); + $section ??= null; + $title = $section !== null ? $sections[$section]['title'] : 'Livewire Material'; + + $destinations = [[ + 'title' => 'Overview', + 'icon' => 'home', + 'url' => route('livewire-material.showcase'), + 'active' => $section === null, + 'bar' => false, + ]]; + + foreach ($sections as $key => $entry) { + $destinations[] = [ + 'title' => $entry['title'], + 'icon' => $entry['icon'], + 'url' => route('livewire-material.section', $key), + 'active' => $key === $section, + 'section' => $entry['group'], + 'bar' => false, + ]; + } +@endphp + - + - @yield('title', 'Livewire Material') + {{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }} @vite(config('livewire-material.showcase.vite')) @livewireStyles - -
-
- Livewire Material + + + + Livewire Material + - + + + + + -
- @foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label) - - @endforeach -
-
-
+ + + + + + - @yield('content') - - + @yield('content') + @livewireScripts diff --git a/resources/views/showcase/section.blade.php b/resources/views/showcase/section.blade.php new file mode 100644 index 00000000..df054b3d --- /dev/null +++ b/resources/views/showcase/section.blade.php @@ -0,0 +1,29 @@ +{{-- One section of the showcase on a page of its own, with the way on to the next. The section's + own heading repeats the app bar's title, so only a screen reader hears it. --}} + +@extends('livewire-material::showcase.layout') + +@php + $keys = array_keys($sections); + $at = array_search($section, $keys, true); + $previous = $keys[$at - 1] ?? null; + $next = $keys[$at + 1] ?? null; +@endphp + +@section('content') +
+ @include('livewire-material::showcase.sections.'.$section) + + +
+@endsection diff --git a/resources/views/showcase/shell.blade.php b/resources/views/showcase/shell.blade.php index 96d04553..fba59b7c 100644 --- a/resources/views/showcase/shell.blade.php +++ b/resources/views/showcase/shell.blade.php @@ -77,7 +77,7 @@
- +
diff --git a/routes/showcase.php b/routes/showcase.php index 57e7df26..fbb137db 100644 --- a/routes/showcase.php +++ b/routes/showcase.php @@ -1,10 +1,12 @@ name('showcase'); +Route::get('/', [ShowcaseController::class, 'index'])->name('showcase'); Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols'); Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol'); @@ -17,3 +19,7 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error']) ->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503']) ->name('error'); Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail'); + +Route::get('{section}', [ShowcaseController::class, 'section']) + ->whereIn('section', array_keys(Sections::all())) + ->name('section'); diff --git a/src/Http/Controllers/ShowcaseController.php b/src/Http/Controllers/ShowcaseController.php new file mode 100644 index 00000000..e063bc58 --- /dev/null +++ b/src/Http/Controllers/ShowcaseController.php @@ -0,0 +1,25 @@ + Sections::all()]); + } + + public function section(string $section): View + { + return view('livewire-material::showcase.section', [ + 'sections' => Sections::all(), + 'section' => $section, + ]); + } +} diff --git a/src/Showcase/Sections.php b/src/Showcase/Sections.php new file mode 100644 index 00000000..481af10b --- /dev/null +++ b/src/Showcase/Sections.php @@ -0,0 +1,39 @@ + + */ + public static function all(): array + { + return [ + 'colour' => ['title' => 'Colour', 'icon' => 'palette', 'group' => 'Foundations', 'description' => 'Every colour role of the generated scheme, light and dark.'], + 'type' => ['title' => 'Type', 'icon' => 'text_fields', 'group' => 'Foundations', 'description' => 'The typescale, emphasized styles included, in Google Sans Flex.'], + 'shape' => ['title' => 'Shape', 'icon' => 'interests', 'group' => 'Foundations', 'description' => 'Corner radii and the 35 M3 Expressive shapes.'], + 'elevation' => ['title' => 'Elevation', 'icon' => 'layers', 'group' => 'Foundations', 'description' => 'Shadows for what floats over content.'], + 'motion' => ['title' => 'Motion', 'icon' => 'animation', 'group' => 'Foundations', 'description' => 'Spatial springs and effects easings.'], + 'icons' => ['title' => 'Icons', 'icon' => 'emoji_symbols', 'group' => 'Foundations', 'description' => 'Every Material Symbol, searchable, outlined and filled.'], + 'buttons' => ['title' => 'Buttons', 'icon' => 'smart_button', 'group' => 'Actions', 'description' => 'Buttons, icon buttons, groups, split buttons and FABs.'], + 'menus' => ['title' => 'Menus', 'icon' => 'menu_open', 'group' => 'Actions', 'description' => 'Menus with items, groups, choices and shortcuts.'], + 'chips' => ['title' => 'Chips', 'icon' => 'sell', 'group' => 'Actions', 'description' => 'Assist, filter, input and suggestion chips.'], + 'communication' => ['title' => 'Communication', 'icon' => 'notifications', 'group' => 'Communication', 'description' => 'Badges, snackbars, tooltips, alerts, stats and empty states.'], + 'progress' => ['title' => 'Progress', 'icon' => 'progress_activity', 'group' => 'Communication', 'description' => 'Linear, circular and wavy progress, and the loading indicator.'], + 'containment' => ['title' => 'Containment', 'icon' => 'web_asset', 'group' => 'Containment', 'description' => 'Cards, lists, dividers, dialogs and sheets.'], + 'carousel' => ['title' => 'Carousel', 'icon' => 'view_carousel', 'group' => 'Containment', 'description' => 'Multi-browse, hero, uncontained and full-screen carousels.'], + 'fields' => ['title' => 'Text fields', 'icon' => 'edit_note', 'group' => 'Inputs', 'description' => 'Text fields, selects, checkboxes, radios, switches, choices and search.'], + 'sliders' => ['title' => 'Sliders', 'icon' => 'tune', 'group' => 'Inputs', 'description' => 'Standard, centered and range sliders in five sizes.'], + 'pickers' => ['title' => 'Date pickers', 'icon' => 'calendar_month', 'group' => 'Inputs', 'description' => 'Docked, modal and input date pickers, single and range.'], + 'timepickers' => ['title' => 'Time pickers', 'icon' => 'schedule', 'group' => 'Inputs', 'description' => 'The dial and input time picker.'], + 'bars' => ['title' => 'App bars and tabs', 'icon' => 'toolbar', 'group' => 'Navigation', 'description' => 'Top app bars, toolbars, tabs, section navigation and the account menu.'], + 'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the app shell.'], + 'data' => ['title' => 'Data', 'icon' => 'table_chart', 'group' => 'Data and pages', 'description' => 'Data tables, sort headers and pagination.'], + 'pages' => ['title' => 'Error pages and mail', 'icon' => 'page_info', 'group' => 'Data and pages', 'description' => 'The HTTP error pages and the Markdown mail theme.'], + ]; + } +} diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index f9c0c4aa..03c9ec7a 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -2,9 +2,9 @@ const MORE = '#menus [aria-label="More"]'; -function showcase() +function showcase(string $section = 'buttons') { - return visit('/material')->waitForEvent('networkidle') + return visit("/material/{$section}")->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); } @@ -14,7 +14,7 @@ function focused(string $expression): string } it('opens a menu on the first item and walks it with the keyboard', function () { - $page = showcase() + $page = showcase('menus') ->assertNoJavaScriptErrors() ->click(MORE) ->assertAttribute(MORE, 'aria-expanded', 'true') @@ -38,7 +38,7 @@ it('opens a menu on the first item and walks it with the keyboard', function () }); it('opens a menu from the keyboard, on the last item with ArrowUp', function () { - $page = showcase(); + $page = showcase('menus'); $page->script("document.querySelector('".MORE."').focus()"); @@ -50,13 +50,13 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function () it('opens a menu the moment the page can be used', function () { // The guard against a light-dismiss press reopening the menu once measured from the page's // time origin, and swallowed every click in the first quarter second. - visit('/material') + visit('/material/menus') ->click(MORE) ->assertAttribute(MORE, 'aria-expanded', 'true'); }); it('closes a menu when an item is chosen, but not one that keeps it open', function () { - $page = showcase(); + $page = showcase('menus'); $page->click(MORE) ->click('#menus [role="menuitem"]:has-text("Download")') @@ -76,7 +76,7 @@ it('shows a tooltip on keyboard focus and hides it on Escape', function () { // Firefox only counts a scripted focus as :focus-visible after one. Then focused directly // rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order // unless full keyboard access is on. - $page->keys('body', 'Tab'); + $page->keys('#content', 'Tab'); $page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()"); $page->assertScript(focused("getAttribute('aria-label') === 'Tonal'")) ->assertScript("{$tooltip}.matches(':popover-open')"); diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php index 9a83893c..90cef8a7 100644 --- a/tests/Browser/CarouselTest.php +++ b/tests/Browser/CarouselTest.php @@ -64,7 +64,7 @@ const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0'; function carouselShowcase(array $options = []) { - return visit('/material', $options) + return visit('/material/carousel', $options) ->waitForEvent('networkidle') ->assertScript("typeof window.Alpine !== 'undefined'"); } diff --git a/tests/Browser/ChipsTest.php b/tests/Browser/ChipsTest.php index dd1613d2..55fd60c8 100644 --- a/tests/Browser/ChipsTest.php +++ b/tests/Browser/ChipsTest.php @@ -74,7 +74,7 @@ function chipProbe() function chipShowcase() { - return visit('/material')->waitForEvent('networkidle') + return visit('/material/chips')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); } @@ -99,7 +99,7 @@ it('toggles a filter chip with a click and with Space, and grows its check in', // A key press first, so the browser is in keyboard modality; focused directly, not tabbed to, // because WebKit leaves form controls out of the Tab order unless full keyboard access is on. - $page->keys('body', 'Tab'); + $page->keys('#content', 'Tab'); $page->script(filterInput('archives').'.focus()'); $page->keys(':focus', 'Space') ->assertScript(filterInput('archives').'.checked') diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php index 91878ad5..7e3e7eeb 100644 --- a/tests/Browser/CommunicationTest.php +++ b/tests/Browser/CommunicationTest.php @@ -3,7 +3,7 @@ const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')"; it('shows a toast as a snackbar, then the next in turn', function () { - $page = visit('/material')->waitForEvent('networkidle') + $page = visit('/material/communication')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); $page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })"); @@ -18,7 +18,7 @@ it('shows a toast as a snackbar, then the next in turn', function () { }); it('shows a toast dispatched as a browser event, as the Toasts concern does', function () { - $page = visit('/material')->waitForEvent('networkidle') + $page = visit('/material/communication')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); // Through Livewire, in the page's own realm: an event built inside Playwright's evaluate @@ -29,7 +29,7 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu }); it('runs a toast\'s action and dismisses it', function () { - $page = visit('/material')->waitForEvent('networkidle') + $page = visit('/material/communication')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); $page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })"); @@ -42,7 +42,7 @@ it('runs a toast\'s action and dismisses it', function () { it('opens a persistent rich tooltip on press', function () { $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; - visit('/material')->waitForEvent('networkidle') + visit('/material/communication')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") ->click('#communication button:has-text("Press for details")') ->assertScript("{$bubble}.matches(':popover-open')"); diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index ab595095..5e76a92d 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -66,7 +66,7 @@ function overlayProbe() function containment() { - return visit('/material')->waitForEvent('networkidle') + return visit('/material/containment')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); } diff --git a/tests/Browser/ProgressTest.php b/tests/Browser/ProgressTest.php index c78f43d2..7504751a 100644 --- a/tests/Browser/ProgressTest.php +++ b/tests/Browser/ProgressTest.php @@ -55,7 +55,7 @@ function progressShowcase(array $options = []) { // networkidle alone can return before a repeated visit has even loaded in Firefox; the // assertion retries until the page is complete and Alpine has started. - return visit('/material', $options)->waitForEvent('networkidle') + return visit('/material/progress', $options)->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); } diff --git a/tests/Browser/ShowcaseTest.php b/tests/Browser/ShowcaseTest.php index 63be6873..7c24acbc 100644 --- a/tests/Browser/ShowcaseTest.php +++ b/tests/Browser/ShowcaseTest.php @@ -5,3 +5,17 @@ it('opens the showcase in a real browser', function () { ->assertSee('Livewire Material') ->assertNoJavaScriptErrors(); }); + +it('moves between sections through the rail and the next-section link, keeping the rail', function () { + $page = visit('/material')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); + + $page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]') + ->assertSee('Variants') + ->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')") + ->assertScript("document.title === 'Buttons · Livewire Material'"); + + $page->click('[data-test="next-section"]') + ->assertScript("location.pathname.endsWith('/material/menus')") + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/SliderTest.php b/tests/Browser/SliderTest.php index 59ce8164..b9aa48a6 100644 --- a/tests/Browser/SliderTest.php +++ b/tests/Browser/SliderTest.php @@ -59,7 +59,7 @@ function sliderProbe() function sliderShowcase() { - return visit('/material')->waitForEvent('networkidle') + return visit('/material/sliders')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); } diff --git a/tests/Browser/ThemeTest.php b/tests/Browser/ThemeTest.php index 8a197d02..23deab3c 100644 --- a/tests/Browser/ThemeTest.php +++ b/tests/Browser/ThemeTest.php @@ -25,9 +25,9 @@ it('keeps the visitor\'s choice over the operating system', function () { ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") ->assertScript(theme('data-theme', 'dark')); - $page->click('@theme-light') + $page->click('[data-theme-option="light"]') ->assertScript(theme('data-theme', 'light')) - ->assertAttribute('@theme-light', 'aria-pressed', 'true') + ->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true') ->assertScript("localStorage.getItem('material-theme') === 'light'"); $page->refresh() @@ -52,7 +52,7 @@ it('adopts an earlier toggle\'s choice once', function () { it('repaints a section that sets its own theme', function () { $swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor"; - visit('/material')->inLightMode() + visit('/material/colour')->inLightMode() ->assertScript(theme('data-theme', 'light')) ->assertScript("{$swatch(0)} !== {$swatch(1)}"); }); diff --git a/tests/Feature/ShowcaseTest.php b/tests/Feature/ShowcaseTest.php index 0dc44a73..ee43ce9a 100644 --- a/tests/Feature/ShowcaseTest.php +++ b/tests/Feature/ShowcaseTest.php @@ -1,6 +1,7 @@ assertSee('Livewire Material'); }); +it('gives every section a page of its own, linked from the overview and the rail', function () { + $overview = $this->withoutVite()->get('/material')->assertOk(); + + foreach (Sections::all() as $key => $section) { + $overview->assertSee(route('livewire-material.section', $key), false); + + $this->withoutVite() + ->get("/material/{$key}") + ->assertOk() + ->assertSee("{$section['title']} · Livewire Material", false) + ->assertSee('id="'.$key.'"', false); + } + + $this->get('/material/not-a-section')->assertNotFound(); +}); + it('does not mount the showcase when disabled', function () { rebootWithShowcase(false); @@ -77,7 +94,7 @@ it('shows every component somewhere in the showcase', function () { $absent = collect(File::files(__DIR__.'/../../resources/views/components')) ->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php')) - ->reject(fn (string $name): bool => preg_match('/]/', $showcase) === 1) + ->reject(fn (string $name): bool => preg_match('/(?:<|<)x-(?:livewire-material::)?'.preg_quote($name, '/').'(?:[\s\/>]|>)/', $showcase) === 1) ->values() ->all();