Files
livewire-material/resources/views/showcase/layout.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 0013e0154d Rename the app shell to the scaffold and give it a FAB slot
Plan step 35: <x-app-shell> becomes <x-scaffold>, M3's and Compose's name
for the structure of bars, rails and panes, with no alias; every package
view, the showcase, the tests, the skills, README and UPGRADE follow. Its
markup and Tailwind classes stay for the navigation rewrite. The new fab
slot places the FAB as Compose's Scaffold does - bottom-end, 16px from the
edges below medium and 24px from it, clear of the navigation bar and of a
snackbar on screen (resources/css/layout/scaffold.css) - and the content
region tells a pane inside that the margin is already drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:51:48 +02:00

180 lines
10 KiB
PHP

{{-- The showcase's frame, and the package's own scaffold 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::scaffold :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="medium: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="w-full"
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>
{{-- 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
<span class="ms-2 me-3 max-expanded:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
<span class="expanded: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::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>