Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id
that is new with every render, and Livewire's morph matches an element
without a wire:key by its id. So every render of the component around
them swapped the popover for a closed copy: an open menu closed, its
listeners stayed behind on the old element (Escape or a press outside
then left aria-expanded="true" on the trigger and focus unreturned), a
keep-open item's wire:click closed its menu when the response came, and
a rich tooltip went on showing the detached bubble, so it never opened
again. <x-carousel>'s row had the same kind of id: after a render it
scrolled without its listeners, and its items stopped re-masking.

The popover, the bubble and the row now carry a wire:key, so the morph
patches them in place and changes the id and anchor name together with
the trigger's, as it already did for everything else. The key goes
through an attribute bag: Livewire compiles a wire:key written in a
template into the key of the loop iteration around it, which would have
given every child component after the menu in a row the same key.

A morph also removes the menu button's ARIA attributes, which only
script writes. menu.js now writes them again after every morph, so the
button of a menu that stays open, and a FAB menu's close look, still
say it is open, and aria-controls names the popover's new id.

A second press on an open menu's button opened it again, render or not:
the popover closes on the press, and the guard against the click that
follows was timed from the toggle event, which is queued and arrives
after that click. It is timed from beforetoggle now.

Browser tests with Livewire probes in Chromium, Firefox and WebKit, and
a render test for the keys and the keys of the child components after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00
2026-09-13 09:56:00 +02:00
2026-09-13 14:35:49 +02:00
2026-09-13 04:59:21 +02:00
2026-09-13 08:48:56 +02:00

Livewire Material

Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS 4.

  • Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive app shell, data tables and pagination.
  • A colour scheme generated from one seed colour with Google's colour science (php artisan material:scheme), light and dark, and a theme that is chosen before the first paint.
  • The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package.
  • Error pages and a Markdown mail theme in the same scheme.
  • A showcase of every component in the application's own scheme, a design guard for tests, and Laravel Boost guidelines and a skill for AI agents.

No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.

Requirements

PHP 8.4+, Laravel 13, Livewire 4, Tailwind CSS 4 with Vite, and Node (for material:scheme).

Installation

The package is served from Gitea. Add the repository and require it:

composer config repositories.livewire-material vcs https://gitea.nonameweb.ch/noNameWEB/livewire-material.git
composer require nonameweb/livewire-material

Stylesheet and script

The application's build imports from vendor/, so Composer packages must be installed before npm run build — in a Dockerfile, copy composer.json, run composer install, then build the assets.

/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@import './material-scheme.css';

@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
// resources/js/app.js
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'

Do not install Alpine separately; Livewire provides it.

Layout

The theme script goes in <head>, before @vite, so the page paints in the visitor's theme:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <x-theme-script />
        @vite(['resources/css/app.css', 'resources/js/app.js'])
    </head>
    <body class="bg-surface font-sans text-on-surface antialiased">
        {{ $slot }}
        <x-toast />
    </body>
</html>

Colour scheme

Generate the scheme from a seed colour. It writes resources/css/material-scheme.css (imported above) and material-scheme.json (read by the mail theme):

php artisan material:scheme "#4f46e5" --variant=tonal-spot

Variants: tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow, fruit-salad. --spec is the colour spec: 2025 (M3 Expressive, the default) or 2021 (M3 as it first shipped, for a palette generated before Expressive). --contrast runs from -1 to 1; --success, --warning and --info seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.

Colour profiles

To let an installation switch between several schemes, list them as profiles in the config (name ⇒ label, seed, variant, and optionally contrast, spec, success, warning, info, which otherwise come from the command's options) and run php artisan material:scheme without a seed: every profile lands in the same stylesheet under <html data-scheme>. Tell the package which one is active — Scheme::resolveProfileUsing(fn () => Setting::get('color_profile')) in a service provider — and the head script, mails and error pages follow it. <x-scheme-picker wire:model="colorProfile" /> lets someone choose, previewing each profile on the page.

Configuration

php artisan vendor:publish --tag=livewire-material-config
  • prefix — components are <x-button>, <x-card>… Set 'm' when a name clashes with the application's own components, and they become <x-m::button>. <x-livewire-material::button> always works.
  • theme.default (light, dark or system), theme.storage_key, theme.legacy_keys (an earlier toggle's localStorage keys, adopted once).
  • theme.meta — keep <meta name="theme-color"> (an installed web app's or a mobile browser's bar) on the resolved theme's surface and the active colour profile, before the first paint and after every change, wire:navigate included; one is added when the page has none (default false).
  • profiles, profile — colour profiles and the default one (see Colour profiles).
  • fields.variant — text fields outlined (default) or filled.
  • pagination — draw Laravel's and Livewire's paginators in M3 (default true).
  • showcase.enabled, showcase.path, showcase.middleware, showcase.vite.
  • node — the Node binary for material:scheme.

Usage

<x-card title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" variant="outlined">
    <x-slot:actions>
        <x-button label="Copy link" icon="content_copy" wire:click="copy" />
        <x-button label="Delete" danger wire:click="$set('confirming', true)" />
    </x-slot:actions>
</x-card>

<x-modal wire:model="confirming" title="Delete this share?" icon="delete">
    Recipients lose access at once.
    <x-slot:actions>
        <x-button label="Cancel" x-on:click="close()" />
        <x-button label="Delete" danger wire:click="delete" />
    </x-slot:actions>
</x-modal>
use NoNameWeb\LivewireMaterial\Concerns\Toasts;

class Shares extends Component
{
    use Toasts;

    public function copy(): void
    {
        $this->success('Link copied');
    }
}

Every component, prop and slot is documented in the Boost skill (resources/boost/skills/livewire-material-development/SKILL.md) and shown in the showcase.

Showcase

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), with a search over every section, example and component (press /).

Testing the design

use NoNameWeb\LivewireMaterial\Testing\DesignGuard;

it('uses only what compiles', function () {
    expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
        ->forbidColours(['tertiary'])
        ->violations())->toBe([]);
});

The guard fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown Material Symbol names and Blade directives written inside component tags.

AI agents

With Laravel Boost, php artisan boost:install (or boost:update --discover) picks up the package's guideline and the livewire-material-development skill.

Developing the package

composer install && npm install
npm run build           # the Workbench's assets (or `npm run dev` while working)
composer serve          # the showcase at http://127.0.0.1:8000/material
vendor/bin/pest --testsuite=Feature
npx playwright install
vendor/bin/pest --testsuite=Browser --browser chrome   # also firefox, safari

Credits

Material Symbols, the M3 Expressive shapes, Google Sans Flex, material-color-utilities and Jetpack Compose Material 3's tokens and algorithms are Google's and the Android Open Source Project's; see NOTICE.

License

MIT. See LICENSE.

S
Description
No description provided
Readme MIT
7.8 MiB
Languages
PHP 40.9%
CSS 22.2%
Blade 20.5%
JavaScript 16.3%
Shell 0.1%