Compare commits
2
Commits
eca6b8fb05
...
889f8a8aa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
889f8a8aa1 | ||
|
|
23218431bf |
@@ -124,7 +124,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
|
||||
compiled views keep that name.
|
||||
- **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
|
||||
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 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
|
||||
emptied through its text input.
|
||||
- **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
|
||||
does.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
```blade
|
||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
@@ -534,6 +534,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
|
||||
@@ -366,6 +366,18 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
/** The clear button: no date (no start and no end), closed, and focus back in the field. */
|
||||
clear() {
|
||||
if (this.open) {
|
||||
this.cancel(false)
|
||||
}
|
||||
|
||||
this.fieldError = ''
|
||||
this.text = ''
|
||||
this.write(null)
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
|
||||
fieldProblem(value) {
|
||||
if (!config.range) {
|
||||
return this.problem(value)
|
||||
|
||||
+11
-1
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
|
||||
// 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
|
||||
// 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) => {
|
||||
const opened = event.newState === 'open'
|
||||
|
||||
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (this.returnFocus && menu.contains(document.activeElement)) {
|
||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
|
||||
this.focusWasInside = false
|
||||
})
|
||||
|
||||
// A press outside closes the menu without pulling focus back to the trigger.
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
|
||||
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
|
||||
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
|
||||
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
|
||||
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
|
||||
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
|
||||
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
|
||||
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
|
||||
|
||||
@@ -48,6 +49,7 @@
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'value' => null,
|
||||
'clearable' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -179,6 +181,19 @@
|
||||
/>
|
||||
|
||||
<x-slot:trailing>
|
||||
@if ($clearable)
|
||||
<button
|
||||
type="button"
|
||||
class="field-trailing field-clear field-button"
|
||||
aria-label="{{ __('Clear') }}"
|
||||
x-on:click="clear()"
|
||||
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
||||
data-field-clear
|
||||
>
|
||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="field-trailing field-button"
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
@extends('livewire-material::showcase.layout')
|
||||
|
||||
@section('content')
|
||||
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
|
||||
<p class="max-w-3xl type-body-lg text-on-surface-variant">
|
||||
Every token and component, in this application's own scheme.
|
||||
</p>
|
||||
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-2 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>
|
||||
<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')
|
||||
@include('livewire-material::showcase.sections.type')
|
||||
@include('livewire-material::showcase.sections.shape')
|
||||
@include('livewire-material::showcase.sections.elevation')
|
||||
@include('livewire-material::showcase.sections.motion')
|
||||
@include('livewire-material::showcase.sections.icons')
|
||||
@include('livewire-material::showcase.sections.buttons')
|
||||
@include('livewire-material::showcase.sections.menus')
|
||||
@include('livewire-material::showcase.sections.communication')
|
||||
@include('livewire-material::showcase.sections.progress')
|
||||
@include('livewire-material::showcase.sections.containment')
|
||||
@include('livewire-material::showcase.sections.carousel')
|
||||
@include('livewire-material::showcase.sections.fields')
|
||||
@include('livewire-material::showcase.sections.chips')
|
||||
@include('livewire-material::showcase.sections.sliders')
|
||||
@include('livewire-material::showcase.sections.pickers')
|
||||
@include('livewire-material::showcase.sections.timepickers')
|
||||
@include('livewire-material::showcase.sections.bars')
|
||||
@include('livewire-material::showcase.sections.navigation')
|
||||
@include('livewire-material::showcase.sections.data')
|
||||
@include('livewire-material::showcase.sections.pages')
|
||||
</main>
|
||||
@foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
|
||||
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
|
||||
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($entries as $key => $entry)
|
||||
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
|
||||
<x-livewire-material::icon :name="$entry['icon']" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-livewire-material::card>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
@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>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<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" />
|
||||
|
||||
<title>@yield('title', 'Livewire Material')</title>
|
||||
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
|
||||
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
|
||||
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
@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)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
<x-slot:top>
|
||||
<x-livewire-material::app-bar :title="$title">
|
||||
<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>
|
||||
|
||||
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
|
||||
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
|
||||
<button
|
||||
type="button"
|
||||
data-test="theme-{{ $choice }}"
|
||||
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
|
||||
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>
|
||||
<x-slot:actions>
|
||||
<span class="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::toast />
|
||||
@yield('content')
|
||||
</x-livewire-material::app-shell>
|
||||
|
||||
@livewireScripts
|
||||
</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
|
||||
@@ -3,7 +3,7 @@
|
||||
'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" x-model="expires" hint="Type a date or pick one" />
|
||||
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<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="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>
|
||||
</x-livewire-material::app-shell>
|
||||
|
||||
+7
-1
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
||||
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/{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'])
|
||||
->name('error');
|
||||
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"]';
|
||||
|
||||
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'");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function focused(string $expression): string
|
||||
}
|
||||
|
||||
it('opens a menu on the first item and walks it with the keyboard', function () {
|
||||
$page = showcase()
|
||||
$page = showcase('menus')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click(MORE)
|
||||
->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 () {
|
||||
$page = showcase();
|
||||
$page = showcase('menus');
|
||||
|
||||
$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 () {
|
||||
// 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.
|
||||
visit('/material')
|
||||
visit('/material/menus')
|
||||
->click(MORE)
|
||||
->assertAttribute(MORE, 'aria-expanded', 'true');
|
||||
});
|
||||
|
||||
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)
|
||||
->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
|
||||
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
|
||||
// unless full keyboard access is on.
|
||||
$page->keys('body', 'Tab');
|
||||
$page->keys('#content', 'Tab');
|
||||
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
||||
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
|
||||
->assertScript("{$tooltip}.matches(':popover-open')");
|
||||
|
||||
@@ -64,7 +64,7 @@ const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
|
||||
|
||||
function carouselShowcase(array $options = [])
|
||||
{
|
||||
return visit('/material', $options)
|
||||
return visit('/material/carousel', $options)
|
||||
->waitForEvent('networkidle')
|
||||
->assertScript("typeof window.Alpine !== 'undefined'");
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function chipProbe()
|
||||
|
||||
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'");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// 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->keys(':focus', 'Space')
|
||||
->assertScript(filterInput('archives').'.checked')
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
||||
|
||||
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'");
|
||||
|
||||
$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 () {
|
||||
$page = visit('/material')->waitForEvent('networkidle')
|
||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||
->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
|
||||
@@ -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 () {
|
||||
$page = visit('/material')->waitForEvent('networkidle')
|
||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||
->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 } })");
|
||||
@@ -42,7 +42,7 @@ it('runs a toast\'s action and dismisses it', function () {
|
||||
it('opens a persistent rich tooltip on press', function () {
|
||||
$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'")
|
||||
->click('#communication button:has-text("Press for details")')
|
||||
->assertScript("{$bubble}.matches(':popover-open')");
|
||||
|
||||
@@ -66,7 +66,7 @@ function overlayProbe()
|
||||
|
||||
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'");
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ class DateProbe extends Component
|
||||
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
|
||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" />
|
||||
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" clearable />
|
||||
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
|
||||
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
|
||||
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" />
|
||||
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" clearable />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
@@ -296,3 +296,18 @@ it('opens a docked picker as a modal one on a compact window', function () {
|
||||
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
|
||||
});
|
||||
|
||||
it('empties a date, or both ends of a range, with its clear button', function () {
|
||||
$page = dateProbe()
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
|
||||
|
||||
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
|
||||
->assertScript("document.querySelector('#expires').textContent === ''")
|
||||
->assertValue('#expires-field', '')
|
||||
->assertScript("document.activeElement.id === 'expires-field'")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
|
||||
|
||||
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
|
||||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||
->assertValue('#trip-field', '');
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ function progressShowcase(array $options = [])
|
||||
{
|
||||
// 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.
|
||||
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'");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,17 @@ it('opens the showcase in a real browser', function () {
|
||||
->assertSee('Livewire Material')
|
||||
->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()
|
||||
{
|
||||
return visit('/material')->waitForEvent('networkidle')
|
||||
return visit('/material/sliders')->waitForEvent('networkidle')
|
||||
->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(theme('data-theme', 'dark'));
|
||||
|
||||
$page->click('@theme-light')
|
||||
$page->click('[data-theme-option="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'");
|
||||
|
||||
$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 () {
|
||||
$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("{$swatch(0)} !== {$swatch(1)}");
|
||||
});
|
||||
|
||||
@@ -163,3 +163,12 @@ it('replaces the hint with the errors for the property and its start and end', f
|
||||
->toContain('data-datepicker-error')
|
||||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
||||
});
|
||||
|
||||
it('offers a clear button on request', function () {
|
||||
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||||
->toContain('data-field-clear')
|
||||
->toContain('x-on:click="clear()"')
|
||||
->toContain('aria-label="Clear"')
|
||||
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||||
->not->toContain('data-field-clear');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,22 @@ it('mounts the showcase when enabled', function () {
|
||||
->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 () {
|
||||
rebootWithShowcase(false);
|
||||
|
||||
@@ -77,7 +94,7 @@ it('shows every component somewhere in the showcase', function () {
|
||||
|
||||
$absent = collect(File::files(__DIR__.'/../../resources/views/components'))
|
||||
->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()
|
||||
->all();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user