Put the showcase in the app shell, with a page per section
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
The showcase is now an overview and one page per section behind a grouped navigation rail, moved between with wire:navigate, instead of one long page under a scrolling top bar. The switch exposed a WebKit bug in the menu: inside a focusable region, WebKit moves focus out of the closing popover before its toggle event, so Escape no longer returned focus to the trigger. The menu now reads it on beforetoggle. 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
23218431bf
commit
889f8a8aa1
@@ -124,7 +124,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos
|
|||||||
|
|
||||||
## Showcase
|
## Showcase
|
||||||
|
|
||||||
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` renders every token and component, in every variant, in the application's own scheme and theme.
|
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).
|
||||||
|
|
||||||
## Testing the design
|
## Testing the design
|
||||||
|
|
||||||
|
|||||||
@@ -589,12 +589,17 @@ pages and the mail theme by an agent in its own worktree. What changed from the
|
|||||||
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
|
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
|
||||||
compiled views keep that name.
|
compiled views keep that name.
|
||||||
- **A test checks that every component appears in the showcase.**
|
- **A test checks that every component appears in the showcase.**
|
||||||
|
- **The showcase is the package's own app shell**: an overview at `/material` and a page per section
|
||||||
|
(`/material/{section}`, `Showcase\Sections`), grouped in the navigation rail, moved between with
|
||||||
|
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.
|
||||||
- **Verified in a fresh Laravel 13.31 application** (`composer create-project`, the package from a
|
- **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`):
|
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
|
a Livewire page with an app bar, tabs, a card, a form with validation, a date picker, a dialog and
|
||||||
a toast works without console errors; `/material` and the 404 page render in its scheme.
|
a toast works without console errors; `/material` and the 404 page render in its scheme.
|
||||||
- **Known gap:** `<x-datepicker>` has no clear button (`<x-timepicker clearable>` does); a date can be
|
- **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
|
||||||
emptied through its text input.
|
does.
|
||||||
|
|
||||||
### Phase 11 — SealShare 2.0.0
|
### Phase 11 — SealShare 2.0.0
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
window.Alpine.data('materialMenu', () => ({
|
window.Alpine.data('materialMenu', () => ({
|
||||||
closedAt: -Infinity,
|
closedAt: -Infinity,
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
|
focusWasInside: false,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// and close() have already done their part, synchronously, because this event is
|
// and close() have already done their part, synchronously, because this event is
|
||||||
// queued and a screen reader or a test reading aria-expanded in between would be told
|
// queued and a screen reader or a test reading aria-expanded in between would be told
|
||||||
// the menu is shut.
|
// the menu is shut.
|
||||||
|
// Whether focus was in the menu is read before it closes: once closed, a browser may
|
||||||
|
// already have handed focus to what had it before the menu opened (WebKit does, when
|
||||||
|
// that was a focusable region around the trigger).
|
||||||
|
this.listen(menu, 'beforetoggle', (event) => {
|
||||||
|
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||||
|
})
|
||||||
|
|
||||||
this.listen(menu, 'toggle', (event) => {
|
this.listen(menu, 'toggle', (event) => {
|
||||||
const opened = event.newState === 'open'
|
const opened = event.newState === 'open'
|
||||||
|
|
||||||
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
this.closedAt = performance.now()
|
this.closedAt = performance.now()
|
||||||
|
|
||||||
if (this.returnFocus && menu.contains(document.activeElement)) {
|
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||||
this.control()?.focus()
|
this.control()?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.focusWasInside = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// A press outside closes the menu without pulling focus back to the trigger.
|
// A press outside closes the menu without pulling focus back to the trigger.
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
@extends('livewire-material::showcase.layout')
|
@extends('livewire-material::showcase.layout')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
|
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-2 pb-16 sm:px-6">
|
||||||
<p class="max-w-3xl type-body-lg text-on-surface-variant">
|
<div class="max-w-3xl space-y-3">
|
||||||
Every token and component, in this application's own scheme.
|
<p class="type-headline-sm">Material 3 Expressive for Laravel and Livewire.</p>
|
||||||
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
@include('livewire-material::showcase.sections.colour')
|
@foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
|
||||||
@include('livewire-material::showcase.sections.type')
|
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
|
||||||
@include('livewire-material::showcase.sections.shape')
|
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
|
||||||
@include('livewire-material::showcase.sections.elevation')
|
|
||||||
@include('livewire-material::showcase.sections.motion')
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@include('livewire-material::showcase.sections.icons')
|
@foreach ($entries as $key => $entry)
|
||||||
@include('livewire-material::showcase.sections.buttons')
|
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
|
||||||
@include('livewire-material::showcase.sections.menus')
|
<div class="flex items-start gap-4">
|
||||||
@include('livewire-material::showcase.sections.communication')
|
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
|
||||||
@include('livewire-material::showcase.sections.progress')
|
<x-livewire-material::icon :name="$entry['icon']" />
|
||||||
@include('livewire-material::showcase.sections.containment')
|
</span>
|
||||||
@include('livewire-material::showcase.sections.carousel')
|
<div class="min-w-0">
|
||||||
@include('livewire-material::showcase.sections.fields')
|
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
|
||||||
@include('livewire-material::showcase.sections.chips')
|
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
|
||||||
@include('livewire-material::showcase.sections.sliders')
|
</div>
|
||||||
@include('livewire-material::showcase.sections.pickers')
|
</div>
|
||||||
@include('livewire-material::showcase.sections.timepickers')
|
</x-livewire-material::card>
|
||||||
@include('livewire-material::showcase.sections.bars')
|
@endforeach
|
||||||
@include('livewire-material::showcase.sections.navigation')
|
</div>
|
||||||
@include('livewire-material::showcase.sections.data')
|
</section>
|
||||||
@include('livewire-material::showcase.sections.pages')
|
@endforeach
|
||||||
</main>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,46 +1,67 @@
|
|||||||
|
{{-- 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>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
|
|
||||||
<title>@yield('title', 'Livewire Material')</title>
|
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
|
||||||
|
|
||||||
<x-livewire-material::theme-script />
|
<x-livewire-material::theme-script />
|
||||||
|
|
||||||
@vite(config('livewire-material.showcase.vite'))
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
|
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||||
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
|
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
|
||||||
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
|
<x-slot:brand>
|
||||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
<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>
|
||||||
|
|
||||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
<x-slot:top>
|
||||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data', 'pages' => 'Pages'] as $anchor => $section)
|
<x-livewire-material::app-bar :title="$title">
|
||||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
<x-slot:navigation>
|
||||||
@endforeach
|
<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>
|
||||||
</nav>
|
</x-slot:navigation>
|
||||||
|
|
||||||
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
|
<x-slot:actions>
|
||||||
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
|
<span class="me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||||
<button
|
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||||
type="button"
|
</x-slot:actions>
|
||||||
data-test="theme-{{ $choice }}"
|
</x-livewire-material::app-bar>
|
||||||
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
|
</x-slot:top>
|
||||||
x-on:click="$store.theme.set('{{ $choice }}')"
|
|
||||||
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'"
|
|
||||||
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()"
|
|
||||||
>{{ $label }}</button>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@yield('content')
|
@yield('content')
|
||||||
|
</x-livewire-material::app-shell>
|
||||||
<x-livewire-material::toast />
|
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{-- 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. --}}
|
||||||
|
|
||||||
|
@extends('livewire-material::showcase.layout')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$keys = array_keys($sections);
|
||||||
|
$at = array_search($section, $keys, true);
|
||||||
|
$previous = $keys[$at - 1] ?? null;
|
||||||
|
$next = $keys[$at + 1] ?? null;
|
||||||
|
@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)
|
||||||
|
|
||||||
|
<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>
|
||||||
|
@endsection
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<x-livewire-material::button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
|
<x-livewire-material::button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
|
||||||
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.showcase') }}#navigation" no-wire-navigate />
|
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-livewire-material::app-shell>
|
</x-livewire-material::app-shell>
|
||||||
|
|||||||
+7
-1
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
|
||||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
||||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
||||||
|
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||||
|
|
||||||
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
|
Route::get('/', [ShowcaseController::class, 'index'])->name('showcase');
|
||||||
|
|
||||||
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
|
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
|
||||||
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
|
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
|
||||||
@@ -17,3 +19,7 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
|
|||||||
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
|
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
|
||||||
->name('error');
|
->name('error');
|
||||||
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
|
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
|
||||||
|
|
||||||
|
Route::get('{section}', [ShowcaseController::class, 'section'])
|
||||||
|
->whereIn('section', array_keys(Sections::all()))
|
||||||
|
->name('section');
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The showcase: an overview of every section, and a page for each.
|
||||||
|
*/
|
||||||
|
class ShowcaseController
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('livewire-material::showcase.index', ['sections' => Sections::all()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function section(string $section): View
|
||||||
|
{
|
||||||
|
return view('livewire-material::showcase.section', [
|
||||||
|
'sections' => Sections::all(),
|
||||||
|
'section' => $section,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoNameWeb\LivewireMaterial\Showcase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The showcase's pages: one per section, grouped as the rail groups them.
|
||||||
|
*/
|
||||||
|
class Sections
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array{title: string, icon: string, group: string, description: string}>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'colour' => ['title' => 'Colour', 'icon' => 'palette', 'group' => 'Foundations', 'description' => 'Every colour role of the generated scheme, light and dark.'],
|
||||||
|
'type' => ['title' => 'Type', 'icon' => 'text_fields', 'group' => 'Foundations', 'description' => 'The typescale, emphasized styles included, in Google Sans Flex.'],
|
||||||
|
'shape' => ['title' => 'Shape', 'icon' => 'interests', 'group' => 'Foundations', 'description' => 'Corner radii and the 35 M3 Expressive shapes.'],
|
||||||
|
'elevation' => ['title' => 'Elevation', 'icon' => 'layers', 'group' => 'Foundations', 'description' => 'Shadows for what floats over content.'],
|
||||||
|
'motion' => ['title' => 'Motion', 'icon' => 'animation', 'group' => 'Foundations', 'description' => 'Spatial springs and effects easings.'],
|
||||||
|
'icons' => ['title' => 'Icons', 'icon' => 'emoji_symbols', 'group' => 'Foundations', 'description' => 'Every Material Symbol, searchable, outlined and filled.'],
|
||||||
|
'buttons' => ['title' => 'Buttons', 'icon' => 'smart_button', 'group' => 'Actions', 'description' => 'Buttons, icon buttons, groups, split buttons and FABs.'],
|
||||||
|
'menus' => ['title' => 'Menus', 'icon' => 'menu_open', 'group' => 'Actions', 'description' => 'Menus with items, groups, choices and shortcuts.'],
|
||||||
|
'chips' => ['title' => 'Chips', 'icon' => 'sell', 'group' => 'Actions', 'description' => 'Assist, filter, input and suggestion chips.'],
|
||||||
|
'communication' => ['title' => 'Communication', 'icon' => 'notifications', 'group' => 'Communication', 'description' => 'Badges, snackbars, tooltips, alerts, stats and empty states.'],
|
||||||
|
'progress' => ['title' => 'Progress', 'icon' => 'progress_activity', 'group' => 'Communication', 'description' => 'Linear, circular and wavy progress, and the loading indicator.'],
|
||||||
|
'containment' => ['title' => 'Containment', 'icon' => 'web_asset', 'group' => 'Containment', 'description' => 'Cards, lists, dividers, dialogs and sheets.'],
|
||||||
|
'carousel' => ['title' => 'Carousel', 'icon' => 'view_carousel', 'group' => 'Containment', 'description' => 'Multi-browse, hero, uncontained and full-screen carousels.'],
|
||||||
|
'fields' => ['title' => 'Text fields', 'icon' => 'edit_note', 'group' => 'Inputs', 'description' => 'Text fields, selects, checkboxes, radios, switches, choices and search.'],
|
||||||
|
'sliders' => ['title' => 'Sliders', 'icon' => 'tune', 'group' => 'Inputs', 'description' => 'Standard, centered and range sliders in five sizes.'],
|
||||||
|
'pickers' => ['title' => 'Date pickers', 'icon' => 'calendar_month', 'group' => 'Inputs', 'description' => 'Docked, modal and input date pickers, single and range.'],
|
||||||
|
'timepickers' => ['title' => 'Time pickers', 'icon' => 'schedule', 'group' => 'Inputs', 'description' => 'The dial and input time picker.'],
|
||||||
|
'bars' => ['title' => 'App bars and tabs', 'icon' => 'toolbar', 'group' => 'Navigation', 'description' => 'Top app bars, toolbars, tabs, section navigation and the account menu.'],
|
||||||
|
'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the app shell.'],
|
||||||
|
'data' => ['title' => 'Data', 'icon' => 'table_chart', 'group' => 'Data and pages', 'description' => 'Data tables, sort headers and pagination.'],
|
||||||
|
'pages' => ['title' => 'Error pages and mail', 'icon' => 'page_info', 'group' => 'Data and pages', 'description' => 'The HTTP error pages and the Markdown mail theme.'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
const MORE = '#menus [aria-label="More"]';
|
const MORE = '#menus [aria-label="More"]';
|
||||||
|
|
||||||
function showcase()
|
function showcase(string $section = 'buttons')
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit("/material/{$section}")->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ function focused(string $expression): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('opens a menu on the first item and walks it with the keyboard', function () {
|
it('opens a menu on the first item and walks it with the keyboard', function () {
|
||||||
$page = showcase()
|
$page = showcase('menus')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->click(MORE)
|
->click(MORE)
|
||||||
->assertAttribute(MORE, 'aria-expanded', 'true')
|
->assertAttribute(MORE, 'aria-expanded', 'true')
|
||||||
@@ -38,7 +38,7 @@ it('opens a menu on the first item and walks it with the keyboard', function ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
|
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
|
||||||
$page = showcase();
|
$page = showcase('menus');
|
||||||
|
|
||||||
$page->script("document.querySelector('".MORE."').focus()");
|
$page->script("document.querySelector('".MORE."').focus()");
|
||||||
|
|
||||||
@@ -50,13 +50,13 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
|
|||||||
it('opens a menu the moment the page can be used', function () {
|
it('opens a menu the moment the page can be used', function () {
|
||||||
// The guard against a light-dismiss press reopening the menu once measured from the page's
|
// The guard against a light-dismiss press reopening the menu once measured from the page's
|
||||||
// time origin, and swallowed every click in the first quarter second.
|
// time origin, and swallowed every click in the first quarter second.
|
||||||
visit('/material')
|
visit('/material/menus')
|
||||||
->click(MORE)
|
->click(MORE)
|
||||||
->assertAttribute(MORE, 'aria-expanded', 'true');
|
->assertAttribute(MORE, 'aria-expanded', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
|
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
|
||||||
$page = showcase();
|
$page = showcase('menus');
|
||||||
|
|
||||||
$page->click(MORE)
|
$page->click(MORE)
|
||||||
->click('#menus [role="menuitem"]:has-text("Download")')
|
->click('#menus [role="menuitem"]:has-text("Download")')
|
||||||
@@ -76,7 +76,7 @@ it('shows a tooltip on keyboard focus and hides it on Escape', function () {
|
|||||||
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
|
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
|
||||||
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
|
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
|
||||||
// unless full keyboard access is on.
|
// unless full keyboard access is on.
|
||||||
$page->keys('body', 'Tab');
|
$page->keys('#content', 'Tab');
|
||||||
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
||||||
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
|
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
|
||||||
->assertScript("{$tooltip}.matches(':popover-open')");
|
->assertScript("{$tooltip}.matches(':popover-open')");
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
|
|||||||
|
|
||||||
function carouselShowcase(array $options = [])
|
function carouselShowcase(array $options = [])
|
||||||
{
|
{
|
||||||
return visit('/material', $options)
|
return visit('/material/carousel', $options)
|
||||||
->waitForEvent('networkidle')
|
->waitForEvent('networkidle')
|
||||||
->assertScript("typeof window.Alpine !== 'undefined'");
|
->assertScript("typeof window.Alpine !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function chipProbe()
|
|||||||
|
|
||||||
function chipShowcase()
|
function chipShowcase()
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit('/material/chips')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ it('toggles a filter chip with a click and with Space, and grows its check in',
|
|||||||
|
|
||||||
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
||||||
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
||||||
$page->keys('body', 'Tab');
|
$page->keys('#content', 'Tab');
|
||||||
$page->script(filterInput('archives').'.focus()');
|
$page->script(filterInput('archives').'.focus()');
|
||||||
$page->keys(':focus', 'Space')
|
$page->keys(':focus', 'Space')
|
||||||
->assertScript(filterInput('archives').'.checked')
|
->assertScript(filterInput('archives').'.checked')
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
||||||
|
|
||||||
it('shows a toast as a snackbar, then the next in turn', function () {
|
it('shows a toast as a snackbar, then the next in turn', function () {
|
||||||
$page = visit('/material')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
|
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
|
||||||
@@ -18,7 +18,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
|
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
|
||||||
$page = visit('/material')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
|
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
|
||||||
@@ -29,7 +29,7 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runs a toast\'s action and dismisses it', function () {
|
it('runs a toast\'s action and dismisses it', function () {
|
||||||
$page = visit('/material')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
|
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
|
||||||
@@ -42,7 +42,7 @@ it('runs a toast\'s action and dismisses it', function () {
|
|||||||
it('opens a persistent rich tooltip on press', function () {
|
it('opens a persistent rich tooltip on press', function () {
|
||||||
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
||||||
|
|
||||||
visit('/material')->waitForEvent('networkidle')
|
visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
->click('#communication button:has-text("Press for details")')
|
->click('#communication button:has-text("Press for details")')
|
||||||
->assertScript("{$bubble}.matches(':popover-open')");
|
->assertScript("{$bubble}.matches(':popover-open')");
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function overlayProbe()
|
|||||||
|
|
||||||
function containment()
|
function containment()
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit('/material/containment')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function progressShowcase(array $options = [])
|
|||||||
{
|
{
|
||||||
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
|
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
|
||||||
// assertion retries until the page is complete and Alpine has started.
|
// assertion retries until the page is complete and Alpine has started.
|
||||||
return visit('/material', $options)->waitForEvent('networkidle')
|
return visit('/material/progress', $options)->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,17 @@ it('opens the showcase in a real browser', function () {
|
|||||||
->assertSee('Livewire Material')
|
->assertSee('Livewire Material')
|
||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves between sections through the rail and the next-section link, keeping the rail', function () {
|
||||||
|
$page = visit('/material')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
|
||||||
|
->assertSee('Variants')
|
||||||
|
->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')")
|
||||||
|
->assertScript("document.title === 'Buttons · Livewire Material'");
|
||||||
|
|
||||||
|
$page->click('[data-test="next-section"]')
|
||||||
|
->assertScript("location.pathname.endsWith('/material/menus')")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function sliderProbe()
|
|||||||
|
|
||||||
function sliderShowcase()
|
function sliderShowcase()
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit('/material/sliders')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ it('keeps the visitor\'s choice over the operating system', function () {
|
|||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
->assertScript(theme('data-theme', 'dark'));
|
->assertScript(theme('data-theme', 'dark'));
|
||||||
|
|
||||||
$page->click('@theme-light')
|
$page->click('[data-theme-option="light"]')
|
||||||
->assertScript(theme('data-theme', 'light'))
|
->assertScript(theme('data-theme', 'light'))
|
||||||
->assertAttribute('@theme-light', 'aria-pressed', 'true')
|
->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true')
|
||||||
->assertScript("localStorage.getItem('material-theme') === 'light'");
|
->assertScript("localStorage.getItem('material-theme') === 'light'");
|
||||||
|
|
||||||
$page->refresh()
|
$page->refresh()
|
||||||
@@ -52,7 +52,7 @@ it('adopts an earlier toggle\'s choice once', function () {
|
|||||||
it('repaints a section that sets its own theme', function () {
|
it('repaints a section that sets its own theme', function () {
|
||||||
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor";
|
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor";
|
||||||
|
|
||||||
visit('/material')->inLightMode()
|
visit('/material/colour')->inLightMode()
|
||||||
->assertScript(theme('data-theme', 'light'))
|
->assertScript(theme('data-theme', 'light'))
|
||||||
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,22 @@ it('mounts the showcase when enabled', function () {
|
|||||||
->assertSee('Livewire Material');
|
->assertSee('Livewire Material');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gives every section a page of its own, linked from the overview and the rail', function () {
|
||||||
|
$overview = $this->withoutVite()->get('/material')->assertOk();
|
||||||
|
|
||||||
|
foreach (Sections::all() as $key => $section) {
|
||||||
|
$overview->assertSee(route('livewire-material.section', $key), false);
|
||||||
|
|
||||||
|
$this->withoutVite()
|
||||||
|
->get("/material/{$key}")
|
||||||
|
->assertOk()
|
||||||
|
->assertSee("<title>{$section['title']} · Livewire Material</title>", false)
|
||||||
|
->assertSee('id="'.$key.'"', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->get('/material/not-a-section')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not mount the showcase when disabled', function () {
|
it('does not mount the showcase when disabled', function () {
|
||||||
rebootWithShowcase(false);
|
rebootWithShowcase(false);
|
||||||
|
|
||||||
@@ -77,7 +94,7 @@ it('shows every component somewhere in the showcase', function () {
|
|||||||
|
|
||||||
$absent = collect(File::files(__DIR__.'/../../resources/views/components'))
|
$absent = collect(File::files(__DIR__.'/../../resources/views/components'))
|
||||||
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'))
|
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'))
|
||||||
->reject(fn (string $name): bool => preg_match('/<x-(?:livewire-material::)?'.preg_quote($name, '/').'[\s\/>]/', $showcase) === 1)
|
->reject(fn (string $name): bool => preg_match('/(?:<|<)x-(?:livewire-material::)?'.preg_quote($name, '/').'(?:[\s\/>]|>)/', $showcase) === 1)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user