Plan step 38 review: ShowcaseController::layout() passes a title
("List-detail · Layout"), but the frame overwrote it with the section's
own, so all three pages and the Layout overview shared one title. The
frame keeps a title it was given; the layout page test asserts each.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
198 lines
11 KiB
PHP
198 lines
11 KiB
PHP
{{-- The showcase's frame, and the package's own scaffold at work: the rail groups every section,
|
|
collapses and expands from `expanded` (840px), 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;
|
|
// A page of a section's own (a canonical layout, ShowcaseController::layout()) names itself.
|
|
$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 />
|
|
|
|
{{-- The application's own entries without its stylesheets (ShowcaseAssetController::scripts()
|
|
says why): the CSS of every showcase page is the bundle linked below, and nothing else. --}}
|
|
@if (($scripts = \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::scripts()) !== [])
|
|
@vite($scripts)
|
|
@endif
|
|
@livewireStyles
|
|
|
|
{{-- all.css, showcase.css and the application's scheme, without the application's build. --}}
|
|
<link rel="stylesheet" href="{{ \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::url() }}" />
|
|
</head>
|
|
<body data-md-showcase>
|
|
<x-livewire-material::scaffold :destinations="$destinations" label="Showcase" rail-width="17rem">
|
|
<x-slot:brand>
|
|
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="md-type-title-lg md-truncate md-focus-ring" data-md-showcase-brand>Livewire Material</a>
|
|
</x-slot:brand>
|
|
|
|
<x-slot:top>
|
|
<x-livewire-material::app-bar variant="search">
|
|
<x-slot:navigation>
|
|
<x-livewire-material::stack as="span" hide-from="medium">
|
|
<x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" />
|
|
</x-livewire-material::stack>
|
|
</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
|
|
data-md-showcase-search
|
|
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-md-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="md-type-body-md md-ink-variant" data-md-showcase-search-hint>
|
|
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="md-type-label-md" data-md-showcase-kbd>/</kbd> to search from anywhere.
|
|
</p>
|
|
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="md-type-body-md md-ink-variant" data-md-showcase-search-hint>
|
|
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="md-state-layer md-focus-ring"
|
|
data-showcase-result
|
|
data-md-showcase-search-result
|
|
>
|
|
<span class="md-type-body-lg md-ink md-truncate" x-text="result.title"></span>
|
|
<span class="md-type-body-sm md-ink-variant md-truncate" x-text="`${result.kind} · ${result.context}`"></span>
|
|
</a>
|
|
</template>
|
|
</x-livewire-material::search>
|
|
</div>
|
|
|
|
<x-slot:actions>
|
|
<x-livewire-material::row gap="space100" align="center">
|
|
{{-- With colour profiles, a preview of each on this page; nothing is stored. --}}
|
|
@if (($profiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles()) !== [])
|
|
<x-livewire-material::menu label="Colour profile" position="bottom-end" data-test="showcase-profiles">
|
|
<x-slot:trigger>
|
|
<x-livewire-material::button icon="palette" tooltip="Colour profile" />
|
|
</x-slot:trigger>
|
|
|
|
@foreach ($profiles as $name => $profile)
|
|
<x-livewire-material::menu-item :label="$profile['label']" x-on:click="$store.theme.previewScheme('{{ $name }}')" data-scheme-preview="{{ $name }}" />
|
|
@endforeach
|
|
</x-livewire-material::menu>
|
|
@endif
|
|
|
|
<x-livewire-material::stack as="span" hide-below="expanded">
|
|
<x-livewire-material::theme-toggle mode="picker" />
|
|
</x-livewire-material::stack>
|
|
<x-livewire-material::stack as="span" hide-from="expanded">
|
|
<x-livewire-material::theme-toggle mode="cycle" />
|
|
</x-livewire-material::stack>
|
|
</x-livewire-material::row>
|
|
</x-slot:actions>
|
|
</x-livewire-material::app-bar>
|
|
</x-slot:top>
|
|
|
|
@yield('content')
|
|
</x-livewire-material::scaffold>
|
|
|
|
@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>
|