Add a search to the showcase
tests / feature (8.5) (push) Successful in 1m14s
tests / lint (push) Successful in 1m1s
tests / feature (8.4) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m22s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m4s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 09:56:00 +02:00
co-authored by Claude Opus 5
parent 7ebcb0565f
commit 51bf034b74
13 changed files with 272 additions and 27 deletions
+1 -1
View File
@@ -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
+5
View File
@@ -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 `<x-menu>`: inside a focusable region (the shell's
`<main tabindex="-1">`), 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 (`<x-search>`, `/` 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 `<x-search>`'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
@@ -18,7 +18,7 @@
}
@endphp
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
<div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
@if ($title)
<h3 class="type-title-md">{{ $title }}</h3>
@endif
+4 -3
View File
@@ -1,12 +1,13 @@
@extends('livewire-material::showcase.layout')
@section('content')
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-2 pb-16 sm:px-6">
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
<div class="max-w-3xl space-y-3">
<p class="type-headline-sm">Material 3 Expressive for Laravel and Livewire.</p>
<h1 class="type-headline-lg">Livewire Material</h1>
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
<p class="type-body-lg text-on-surface-variant">
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 <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
</p>
</div>
+100 -2
View File
@@ -48,13 +48,91 @@
</x-slot:brand>
<x-slot:top>
<x-livewire-material::app-bar :title="$title">
<x-livewire-material::app-bar variant="search">
<x-slot:navigation>
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
</x-slot:navigation>
{{-- 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 <x-search>'s own (`results`, `open`), which the slot also sees. --}}
<div
class="mx-auto w-full max-w-2xl"
x-data="{
query: '',
entries: null,
async load() {
this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
},
get matches() {
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
if (! this.entries || words.length === 0) return [];
const phrase = words.join(' ');
return this.entries
.map((entry) => {
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
if (! words.every((word) => text.includes(word))) return null;
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
return { ...entry, rank };
})
.filter(Boolean)
.sort((a, b) => a.rank - b.rank)
.slice(0, 30);
},
go(event, result) {
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
event.preventDefault();
const url = new URL(result.url, window.location.href);
const search = this.$root.querySelector('[data-search]');
this.query = '';
if (search) window.Alpine.$data(search).close();
if (url.pathname === window.location.pathname) {
window.location.hash = url.hash;
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
} else {
window.Livewire.navigate(url.pathname + url.hash);
}
},
shortcut(event) {
const typing = event.target.closest('input, textarea, select, [contenteditable]');
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
event.preventDefault();
document.getElementById('showcase-search').focus();
}
},
}"
x-on:keydown.window="shortcut($event)"
>
<x-livewire-material::search
id="showcase-search"
placeholder="Search components, examples and sections"
x-model="query"
x-on:focus.once="load()"
x-on:keydown.enter.prevent="go($event, matches[0])"
>
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
</p>
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
Nothing matches <span x-text="query.trim()"></span>.
</p>
<template x-for="result in matches" x-bind:key="result.url + result.title">
<a
x-bind:href="result.url"
x-on:click="go($event, result)"
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
data-showcase-result
>
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
</a>
</template>
</x-livewire-material::search>
</div>
<x-slot:actions>
<span class="me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
</x-slot:actions>
</x-livewire-material::app-bar>
@@ -64,5 +142,25 @@
</x-livewire-material::app-shell>
@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. --}}
<script data-navigate-once>
document.addEventListener('livewire:navigated', () => {
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
const land = () => target()?.scrollIntoView({ block: 'start' });
// After the swap's own scroll to the top and the page transition, and once more when
// the page's components have settled their size.
requestAnimationFrame(() => requestAnimationFrame(land));
setTimeout(() => {
const box = target()?.getBoundingClientRect();
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
land();
}
}, 400);
});
</script>
</body>
</html>
+21 -14
View File
@@ -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')
<div class="mx-auto w-full max-w-6xl space-y-16 px-4 pt-2 pb-16 sm:px-6 [&>section>h2]:sr-only">
@include('livewire-material::showcase.sections.'.$section)
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6">
<header class="mb-6 space-y-1">
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
</header>
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
@if ($previous)
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
@else
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
@endif
<div class="space-y-16 [&>section>h2]:sr-only">
@include('livewire-material::showcase.sections.'.$section)
@if ($next)
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
@endif
</nav>
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
@if ($previous)
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
@else
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
@endif
@if ($next)
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
@endif
</nav>
</div>
</div>
@endsection
@@ -1,6 +1,6 @@
@php
$examples = [
'docked' => <<<'BLADE'
'Docked' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
@@ -13,7 +13,7 @@
</div>
</div>
BLADE,
'modal and modal input' => <<<'BLADE'
'Modal and modal input' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
@@ -26,7 +26,7 @@
</div>
</div>
BLADE,
'range' => <<<'BLADE'
'Range' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
<div class="grid content-start gap-4">
<x-datepicker label="Trip" range x-model="trip" />
@@ -39,7 +39,7 @@
</div>
</div>
BLADE,
'limits, errors and states' => <<<'BLADE'
'Limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
@@ -1,6 +1,6 @@
@php
$examples = [
'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE'
'12 and 24 hours, outlined and filled' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
@@ -15,7 +15,7 @@
</div>
</div>
BLADE,
'Time pickers: steps and limits' => <<<'BLADE'
'Steps and limits' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
+2
View File
@@ -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');
@@ -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());
}
}
+69
View File
@@ -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<array{title: string, kind: string, context: string, url: string, keywords: string}>
*/
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 (['/&lt;x-([a-z0-9-]+)&gt;/', '/<x-(?:livewire-material::)?([a-z0-9-]+)(?=[\\s\\/>])/'] 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' => "<x-{$tag}>", '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<string, array{title: string, icon: string, group: string, description: string}>
*/
+34
View File
@@ -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]', '<x-datepicker>');
$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');
});
+20
View File
@@ -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('<x-datepicker>', '<x-button>', '<x-app-shell>')
->and($index->firstWhere('title', '<x-datepicker>')['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);