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
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:
co-authored by
Claude Opus 5
parent
7ebcb0565f
commit
51bf034b74
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user