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
167 lines
9.2 KiB
PHP
167 lines
9.2 KiB
PHP
{{-- 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
|
|
|
|
<!DOCTYPE html>
|
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
<meta name="robots" content="noindex" />
|
|
|
|
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
|
|
|
|
<x-livewire-material::theme-script />
|
|
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body class="bg-surface font-sans text-on-surface antialiased">
|
|
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
|
|
<x-slot:brand>
|
|
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
|
</x-slot:brand>
|
|
|
|
<x-slot:top>
|
|
<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="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>
|
|
</x-slot:top>
|
|
|
|
@yield('content')
|
|
</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>
|