From 51bf034b74813575a53aa81ee8ead64a315f35ef Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 09:56:00 +0200 Subject: [PATCH] Add a search to the showcase A search app bar looks through every section, example and component, opens the section, or the example at its anchor on its page, and is reached from anywhere with / or Ctrl+K. Section pages now carry their own heading. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- README.md | 2 +- docs/plans/livewire-material.md | 5 + .../showcase/components/example.blade.php | 2 +- resources/views/showcase/index.blade.php | 7 +- resources/views/showcase/layout.blade.php | 102 +++++++++++++++++- resources/views/showcase/section.blade.php | 35 +++--- .../views/showcase/sections/pickers.blade.php | 8 +- .../showcase/sections/timepickers.blade.php | 4 +- routes/showcase.php | 2 + src/Http/Controllers/ShowcaseController.php | 9 ++ src/Showcase/Sections.php | 69 ++++++++++++ tests/Browser/ShowcaseTest.php | 34 ++++++ tests/Feature/ShowcaseTest.php | 20 ++++ 13 files changed, 272 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index bc4ca602..ba0e8a82 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` 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). +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), with a search over every section, example and component (press `/`). ## Testing the design diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index f6273b00..ce9e217f 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -594,6 +594,11 @@ pages and the mail theme by an agent in its own worktree. What changed from the 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. +- **The showcase has a search** in a search app bar (``, `/` or Ctrl+K): an index of every + section, every example (by its anchor) and every component, read from the section views and fetched + as `/material/search.json` on first focus. Names in a slot's Alpine scope must keep clear of the + component's own: `results` and `open` in ``'s scope hid the page's. wire:navigate keeps a + URL's hash but scrolls to the top, so the layout lands on the hash after the swap settles. - **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 diff --git a/resources/views/showcase/components/example.blade.php b/resources/views/showcase/components/example.blade.php index 6d9d9e9d..3d14cd37 100644 --- a/resources/views/showcase/components/example.blade.php +++ b/resources/views/showcase/components/example.blade.php @@ -18,7 +18,7 @@ } @endphp -
+
@if ($title)

{{ $title }}

@endif diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 36205e28..a5939f05 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -1,12 +1,13 @@ @extends('livewire-material::showcase.layout') @section('content') -
+
-

Material 3 Expressive for Laravel and Livewire.

+

Livewire Material

+

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. + navigation, search for one above (or press /), or start below.

diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index f8414a7c..0ebdd824 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -48,13 +48,91 @@ - + + {{-- The search looks through every section, example and component: the index is + fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names + keep clear of 's own (`results`, `open`), which the slot also sees. --}} +
+ +

+ Type a component (datepicker), an example or a section. Press / to search from anywhere. +

+

+ Nothing matches “”. +

+ +
+
+ - +
@@ -64,5 +142,25 @@ @livewireScripts + + {{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an + example on another page lands on the example. --}} + diff --git a/resources/views/showcase/section.blade.php b/resources/views/showcase/section.blade.php index df054b3d..269fca53 100644 --- a/resources/views/showcase/section.blade.php +++ b/resources/views/showcase/section.blade.php @@ -1,5 +1,5 @@ -{{-- 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. --}} +{{-- One section of the showcase on a page of its own, with the way on to the next. The page's + heading names the section, so the section's own heading is only for a screen reader. --}} @extends('livewire-material::showcase.layout') @@ -11,19 +11,26 @@ @endphp @section('content') -
- @include('livewire-material::showcase.sections.'.$section) +
+
+

{{ $sections[$section]['group'] }}

+

{{ $sections[$section]['title'] }}

+
- + +
@endsection diff --git a/resources/views/showcase/sections/pickers.blade.php b/resources/views/showcase/sections/pickers.blade.php index 4c7de78a..71f8c1d2 100644 --- a/resources/views/showcase/sections/pickers.blade.php +++ b/resources/views/showcase/sections/pickers.blade.php @@ -1,6 +1,6 @@ @php $examples = [ - 'docked' => <<<'BLADE' + 'Docked' => <<<'BLADE'
@@ -13,7 +13,7 @@
BLADE, - 'modal and modal input' => <<<'BLADE' + 'Modal and modal input' => <<<'BLADE'
@@ -26,7 +26,7 @@
BLADE, - 'range' => <<<'BLADE' + 'Range' => <<<'BLADE'
@@ -39,7 +39,7 @@
BLADE, - 'limits, errors and states' => <<<'BLADE' + 'Limits, errors and states' => <<<'BLADE'
diff --git a/resources/views/showcase/sections/timepickers.blade.php b/resources/views/showcase/sections/timepickers.blade.php index 4a780e99..0d7675f3 100644 --- a/resources/views/showcase/sections/timepickers.blade.php +++ b/resources/views/showcase/sections/timepickers.blade.php @@ -1,6 +1,6 @@ @php $examples = [ - 'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE' + '12 and 24 hours, outlined and filled' => <<<'BLADE'
@@ -15,7 +15,7 @@
BLADE, - 'Time pickers: steps and limits' => <<<'BLADE' + 'Steps and limits' => <<<'BLADE'
diff --git a/routes/showcase.php b/routes/showcase.php index fbb137db..1135c8a4 100644 --- a/routes/showcase.php +++ b/routes/showcase.php @@ -20,6 +20,8 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error']) ->name('error'); Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail'); +Route::get('search.json', [ShowcaseController::class, 'search'])->name('search'); + 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 index e063bc58..826c84a1 100644 --- a/src/Http/Controllers/ShowcaseController.php +++ b/src/Http/Controllers/ShowcaseController.php @@ -3,6 +3,7 @@ namespace NoNameWeb\LivewireMaterial\Http\Controllers; use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; use NoNameWeb\LivewireMaterial\Showcase\Sections; /** @@ -22,4 +23,12 @@ class ShowcaseController 'section' => $section, ]); } + + /** + * The search's index, fetched when the search is first focused rather than written into every page. + */ + public function search(): JsonResponse + { + return response()->json(Sections::index()); + } } diff --git a/src/Showcase/Sections.php b/src/Showcase/Sections.php index 481af10b..19a3c47c 100644 --- a/src/Showcase/Sections.php +++ b/src/Showcase/Sections.php @@ -2,11 +2,80 @@ namespace NoNameWeb\LivewireMaterial\Showcase; +use Illuminate\Support\Str; +use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider; + /** * The showcase's pages: one per section, grouped as the rail groups them. */ class Sections { + /** + * What the showcase's search looks through: every section, every example in it (linked to the + * example's anchor), and every component, linked to the first section that introduces it. + * + * @return list + */ + public static function index(): array + { + $sections = static::all(); + $components = collect(glob(LivewireMaterialServiceProvider::componentPath().'/*.blade.php')) + ->map(fn (string $path): string => basename($path, '.blade.php')) + ->all(); + $sources = collect($sections)->map(fn (array $section, string $key): string => (string) file_get_contents(static::path($key))); + + $entries = []; + $homes = []; + + foreach ($sections as $key => $section) { + $url = route('livewire-material.section', $key, false); + + $entries[] = ['title' => $section['title'], 'kind' => 'Section', 'context' => $section['group'], 'url' => $url, 'keywords' => $section['description']]; + + preg_match_all("/^\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*=>\\s*<<<'BLADE'/m", $sources[$key], $examples); + + foreach ($examples[1] as $title) { + $title = stripslashes($title); + $entries[] = ['title' => $title, 'kind' => 'Example', 'context' => $section['title'], 'url' => $url.'#'.static::anchor($title), 'keywords' => '']; + } + } + + // A section that names a component in its introduction is its home; otherwise the first that uses it. + foreach (['/<x-([a-z0-9-]+)>/', '/])/'] as $pattern) { + foreach ($sources as $key => $source) { + preg_match_all($pattern, $source, $tags); + + foreach ($tags[1] as $tag) { + if (in_array($tag, $components, true)) { + $homes[$tag] ??= $key; + } + } + } + } + + foreach ($homes as $tag => $key) { + $entries[] = ['title' => "", 'kind' => 'Component', 'context' => $sections[$key]['title'], 'url' => route('livewire-material.section', $key, false), 'keywords' => str_replace('-', ' ', $tag)]; + } + + return $entries; + } + + /** + * The anchor of an example on its section's page. + */ + public static function anchor(string $title): string + { + return 'example-'.Str::slug($title); + } + + /** + * A section's view file. + */ + public static function path(string $key): string + { + return dirname(__DIR__, 2).'/resources/views/showcase/sections/'.$key.'.blade.php'; + } + /** * @return array */ diff --git a/tests/Browser/ShowcaseTest.php b/tests/Browser/ShowcaseTest.php index 7c24acbc..566dc917 100644 --- a/tests/Browser/ShowcaseTest.php +++ b/tests/Browser/ShowcaseTest.php @@ -19,3 +19,37 @@ it('moves between sections through the rail and the next-section link, keeping t ->assertScript("location.pathname.endsWith('/material/menus')") ->assertNoJavaScriptErrors(); }); + +it('finds a component from anywhere and opens its section', function () { + $page = visit('/material/buttons')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); + + $page->keys('#content', '/') + ->assertScript("document.activeElement.id === 'showcase-search'"); + + $page->type('#showcase-search', 'datepicker') + ->assertSeeIn('[data-search-view]', ''); + + $page->keys('#showcase-search', 'Enter') + ->assertScript("location.pathname.endsWith('/material/pickers')") + ->assertScript("document.title === 'Date pickers · Livewire Material'"); +}); + +it('opens an example on another page at the example', function () { + $page = visit('/material')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); + + $page->click('#showcase-search') + ->type('#showcase-search', 'split button') + ->click('[data-showcase-result]:has-text("Example")') + ->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#example-')") + ->assertScript('(() => { const box = document.getElementById(decodeURIComponent(location.hash.slice(1))).getBoundingClientRect(); return box.top >= 0 && box.top < innerHeight; })()'); +}); + +it('says so when nothing matches', function () { + visit('/material')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") + ->click('#showcase-search') + ->type('#showcase-search', 'zzzz') + ->assertSeeIn('[data-search-view]', 'Nothing matches'); +}); diff --git a/tests/Feature/ShowcaseTest.php b/tests/Feature/ShowcaseTest.php index ee43ce9a..5e977f43 100644 --- a/tests/Feature/ShowcaseTest.php +++ b/tests/Feature/ShowcaseTest.php @@ -45,6 +45,26 @@ it('gives every section a page of its own, linked from the overview and the rail $this->get('/material/not-a-section')->assertNotFound(); }); +it('indexes every section, example and component for the search, each linking to a place that exists', function () { + $index = collect($this->getJson('/material/search.json')->assertOk()->json()); + + expect($index->where('kind', 'Section')->pluck('title')->all()) + ->toBe(collect(Sections::all())->pluck('title')->all()) + ->and($index->where('kind', 'Component')->pluck('title')->all()) + ->toContain('', '', '') + ->and($index->firstWhere('title', '')['url'])->toBe('/material/pickers'); + + $index->where('kind', 'Example') + ->groupBy(fn (array $entry): string => strtok($entry['url'], '#')) + ->each(function ($examples, string $page): void { + $html = $this->withoutVite()->get($page)->assertOk()->getContent(); + + foreach ($examples as $example) { + expect($html)->toContain('id="'.substr($example['url'], strpos($example['url'], '#') + 1).'"'); + } + }); +}); + it('does not mount the showcase when disabled', function () { rebootWithShowcase(false);