Add the Material 3 Expressive foundation
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
Colour, shape, type, elevation and motion as tokens and Tailwind utilities; `php artisan material:scheme`, which generates an app's colour roles with Google's material-color-utilities (spec 2025); the theme head script with light, dark and system and its Alpine store; Google Sans Flex; every Material Symbol (4,135, outlined and filled) drawn by <x-icon> without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as <x-shape>; the x-figure directive; the Toasts concern; DesignGuard for applications' tests; and a showcase with every token, both themes side by side and an icon search. Colour utilities are `@theme inline`, so a section with its own data-theme repaints; without it they resolve once on :root. 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
9b53891a8e
commit
b48e879254
@@ -0,0 +1,34 @@
|
||||
{{-- A Material Symbol (Rounded, weight 400, grade 0, optical size 24), inline.
|
||||
|
||||
`name` is Google's name for it, underscores and all (`arrow_back`), as listed on
|
||||
fonts.google.com/icons; every symbol Google publishes is available, and an unknown name
|
||||
throws. `filled` draws the filled state, which in M3 means active or selected.
|
||||
|
||||
Decorative by default and hidden from screen readers, because nearly every icon sits
|
||||
beside words that already say what it means. Pass `label` when the icon alone carries
|
||||
the meaning.
|
||||
|
||||
24px unless the caller sizes it. The default is added only when no size class is passed,
|
||||
rather than merged under one: `size-6 size-4` compiles to whichever Tailwind emits last,
|
||||
not to the one the caller wrote. --}}
|
||||
|
||||
@props([
|
||||
'name',
|
||||
'filled' => false,
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1;
|
||||
|
||||
$attributes = $attributes
|
||||
->class(['shrink-0', 'size-6' => ! $sized])
|
||||
->merge(array_filter([
|
||||
'aria-hidden' => $label === null ? 'true' : null,
|
||||
'aria-label' => $label,
|
||||
'role' => $label === null ? null : 'img',
|
||||
'focusable' => 'false',
|
||||
]));
|
||||
@endphp
|
||||
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::symbol($name, (bool) $filled, $attributes) }}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{-- One of M3 Expressive's shapes, filled in the colour of the text around it.
|
||||
|
||||
Decoration: a large tonal shape behind an illustration, a scalloped container behind an
|
||||
icon. `name` is the shape's kebab-case name (`cookie-9`, `soft-burst`, `pill`). Every
|
||||
shape fills a 100-unit box to within 2 units, so one sits behind another at the same size.
|
||||
|
||||
Hidden from assistive tech, and sized by its caller. --}}
|
||||
|
||||
@props(['name'])
|
||||
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::shape($name, $attributes->merge(['aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite.
|
||||
|
||||
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
|
||||
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
|
||||
operating system, now and whenever it changes. The result is written to
|
||||
<html data-theme>, which is the only thing the stylesheet keys on; the choice itself is
|
||||
<html data-theme-choice>, where `$store.theme` picks it up.
|
||||
|
||||
A value under one of `theme.legacy_keys` — an earlier toggle's key, which may be
|
||||
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
||||
visitor who never chose, so changing `theme.default` later reaches them too.
|
||||
|
||||
<html> survives wire:navigate, so this only has to run on a full load. --}}
|
||||
|
||||
@php
|
||||
$theme = config('livewire-material.theme');
|
||||
|
||||
$settings = [
|
||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||
];
|
||||
@endphp
|
||||
|
||||
<script>
|
||||
(function (settings) {
|
||||
var root = document.documentElement;
|
||||
var media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
|
||||
var choice = settings.default;
|
||||
|
||||
try {
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
|
||||
if (valid(stored)) {
|
||||
choice = stored;
|
||||
} else {
|
||||
settings.legacy.forEach(function (key) {
|
||||
var legacy = (localStorage.getItem(key) || '').replace(/"/g, '');
|
||||
|
||||
if (valid(legacy)) {
|
||||
choice = legacy;
|
||||
localStorage.setItem(settings.key, legacy);
|
||||
}
|
||||
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Private windows and blocked site data throw on access; the default stands.
|
||||
}
|
||||
|
||||
var apply = function () {
|
||||
var current = root.getAttribute('data-theme-choice');
|
||||
|
||||
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
|
||||
};
|
||||
|
||||
root.setAttribute('data-theme-key', settings.key);
|
||||
root.setAttribute('data-theme-choice', choice);
|
||||
apply();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
})(@json($settings));
|
||||
</script>
|
||||
@@ -1,12 +1,16 @@
|
||||
@extends('livewire-material::showcase.layout')
|
||||
|
||||
@section('content')
|
||||
<main class="mx-auto max-w-5xl px-4 py-10">
|
||||
<h1 class="text-3xl">Livewire Material</h1>
|
||||
|
||||
<p class="mt-2">
|
||||
Every component, in every variant, colour, size and state, in this application's own scheme.
|
||||
Sections appear here as the components land.
|
||||
<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. Components appear here as they land.
|
||||
</p>
|
||||
|
||||
@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')
|
||||
</main>
|
||||
@endsection
|
||||
|
||||
@@ -7,10 +7,37 @@
|
||||
|
||||
<title>@yield('title', 'Livewire Material')</title>
|
||||
|
||||
<x-theme-script />
|
||||
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="min-h-screen antialiased">
|
||||
<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 flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a>
|
||||
|
||||
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data>
|
||||
@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>
|
||||
|
||||
@yield('content')
|
||||
|
||||
@livewireScripts
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@php
|
||||
$groups = [
|
||||
'Primary' => ['bg-primary', 'bg-on-primary', 'bg-primary-container', 'bg-on-primary-container', 'bg-inverse-primary', 'bg-primary-fixed', 'bg-primary-fixed-dim', 'bg-on-primary-fixed', 'bg-on-primary-fixed-variant'],
|
||||
'Secondary' => ['bg-secondary', 'bg-on-secondary', 'bg-secondary-container', 'bg-on-secondary-container', 'bg-secondary-fixed', 'bg-secondary-fixed-dim', 'bg-on-secondary-fixed', 'bg-on-secondary-fixed-variant'],
|
||||
'Tertiary' => ['bg-tertiary', 'bg-on-tertiary', 'bg-tertiary-container', 'bg-on-tertiary-container', 'bg-tertiary-fixed', 'bg-tertiary-fixed-dim', 'bg-on-tertiary-fixed', 'bg-on-tertiary-fixed-variant'],
|
||||
'Error' => ['bg-error', 'bg-on-error', 'bg-error-container', 'bg-on-error-container', 'bg-inverse-error'],
|
||||
'Success' => ['bg-success', 'bg-on-success', 'bg-success-container', 'bg-on-success-container', 'bg-inverse-success'],
|
||||
'Warning' => ['bg-warning', 'bg-on-warning', 'bg-warning-container', 'bg-on-warning-container', 'bg-inverse-warning'],
|
||||
'Info' => ['bg-info', 'bg-on-info', 'bg-info-container', 'bg-on-info-container', 'bg-inverse-info'],
|
||||
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
|
||||
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="colour" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Colour</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
Every role as <code>bg-*</code>, <code>text-*</code> and <code>border-*</code>, generated by
|
||||
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||
<h3 class="type-title-md capitalize">{{ $theme }}</h3>
|
||||
|
||||
@foreach ($groups as $group => $roles)
|
||||
<div class="space-y-2">
|
||||
<h4 class="type-label-lg text-on-surface-variant">{{ $group }}</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
@foreach ($roles as $role)
|
||||
<div class="flex items-center gap-2">
|
||||
<span @class(['size-8 shrink-0 rounded-corner-sm border border-outline-variant', $role])></span>
|
||||
<span class="min-w-0 truncate type-body-sm">{{ \Illuminate\Support\Str::after($role, 'bg-') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
<section id="elevation" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Elevation</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
Five shadow levels as <code>shadow-elevation-*</code>, for what floats over content.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 rounded-corner-lg bg-surface-container-low p-6 sm:grid-cols-5">
|
||||
@foreach (['shadow-elevation-1', 'shadow-elevation-2', 'shadow-elevation-3', 'shadow-elevation-4', 'shadow-elevation-5'] as $shadow)
|
||||
<div @class(['flex h-24 items-center justify-center rounded-corner-md bg-surface-container-high', $shadow])>
|
||||
<code class="type-label-md">{{ $shadow }}</code>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
<section
|
||||
id="icons"
|
||||
class="scroll-mt-24 space-y-6"
|
||||
x-data="{
|
||||
names: {{ json_encode(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames()) }},
|
||||
base: '{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols',
|
||||
query: '',
|
||||
filled: false,
|
||||
get matches() {
|
||||
const query = this.query.trim().toLowerCase().replaceAll(' ', '_');
|
||||
|
||||
return (query === '' ? this.names : this.names.filter((name) => name.includes(query))).slice(0, 120);
|
||||
},
|
||||
}"
|
||||
>
|
||||
<h2 class="type-headline-md">Icons</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
Every Material Symbol (Rounded, 400, grade 0, 24px) as <code><x-icon name="…" /></code>, outlined or <code>filled</code>.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<label class="flex min-w-72 flex-1 items-center gap-2 rounded-corner-xs border border-outline px-3 py-2 focus-within:outline-3 focus-within:outline-secondary">
|
||||
<x-icon name="search" class="size-5 text-on-surface-variant" />
|
||||
<input type="search" x-model.debounce.150ms="query" placeholder="Search {{ number_format(count(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames())) }} symbols" class="w-full bg-transparent type-body-lg outline-none" />
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 type-label-lg">
|
||||
<input type="checkbox" x-model="filled" class="size-4 accent-primary" />
|
||||
Filled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul class="grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-2">
|
||||
<template x-for="name in matches" x-bind:key="name">
|
||||
<li class="flex flex-col items-center gap-2 rounded-corner-md bg-surface-container p-3 text-center">
|
||||
<span
|
||||
class="size-6 bg-current"
|
||||
x-bind:style="{
|
||||
mask: `url('${base}/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
|
||||
}"
|
||||
></span>
|
||||
<code class="w-full break-all type-label-sm text-on-surface-variant" x-text="name"></code>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -0,0 +1,35 @@
|
||||
<section id="motion" class="scroll-mt-24 space-y-6" x-data="{ moved: false, figure: 1204 }">
|
||||
<h2 class="type-headline-md">Motion</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
M3 Expressive's springs as <code>ease-spatial-*</code> and <code>ease-effects-*</code>, each with its
|
||||
<code>--md-sys-motion-*-duration</code>. Reduced motion makes all of them instant.
|
||||
</p>
|
||||
|
||||
<button type="button" class="state-layer focus-ring rounded-corner-full bg-primary px-6 py-2.5 type-label-lg text-on-primary" x-on:click="moved = ! moved">
|
||||
Move
|
||||
</button>
|
||||
|
||||
<div class="space-y-3 rounded-corner-lg bg-surface-container p-4">
|
||||
@foreach (['spatial-fast', 'spatial-default', 'spatial-slow', 'effects-fast', 'effects-default', 'effects-slow'] as $spring)
|
||||
<div class="grid grid-cols-[9rem_1fr] items-center gap-4">
|
||||
<code class="type-label-md text-on-surface-variant">ease-{{ $spring }}</code>
|
||||
<div class="h-8">
|
||||
<div
|
||||
class="size-8 rounded-corner-full bg-tertiary"
|
||||
style="transition: translate var(--md-sys-motion-{{ $spring }}-duration) var(--md-sys-motion-{{ $spring }})"
|
||||
x-bind:style="moved && { translate: '16rem' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-corner-lg bg-surface-container p-4">
|
||||
<p class="type-emphasized-display-sm tabular-nums" x-figure x-text="figure.toLocaleString('en') + ' files'">1,204 files</p>
|
||||
<button type="button" class="state-layer focus-ring rounded-corner-full px-4 py-2 type-label-lg text-primary" x-on:click="figure = Math.round(Math.random() * 5000)">
|
||||
Change the figure
|
||||
</button>
|
||||
<code class="type-label-md text-on-surface-variant">x-figure</code>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
<section id="shape" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Shape</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
The corner scale as <code>rounded-corner-*</code>, and M3 Expressive's shapes as <code><x-shape name="…" /></code>.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
@foreach (['rounded-corner-none', 'rounded-corner-xs', 'rounded-corner-sm', 'rounded-corner-md', 'rounded-corner-lg', 'rounded-corner-lg-increased', 'rounded-corner-xl', 'rounded-corner-xl-increased', 'rounded-corner-xxl', 'rounded-corner-full'] as $corner)
|
||||
<div class="space-y-2">
|
||||
<div @class(['h-20 bg-primary-container', $corner])></div>
|
||||
<code class="type-label-md text-on-surface-variant">{{ $corner }}</code>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 sm:grid-cols-5 lg:grid-cols-7">
|
||||
@foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape)
|
||||
<div class="space-y-2 text-center">
|
||||
<x-shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
|
||||
<code class="type-label-md text-on-surface-variant">{{ $shape }}</code>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
@php
|
||||
$styles = [
|
||||
['type-display-lg', 'type-emphasized-display-lg'],
|
||||
['type-display-md', 'type-emphasized-display-md'],
|
||||
['type-display-sm', 'type-emphasized-display-sm'],
|
||||
['type-headline-lg', 'type-emphasized-headline-lg'],
|
||||
['type-headline-md', 'type-emphasized-headline-md'],
|
||||
['type-headline-sm', 'type-emphasized-headline-sm'],
|
||||
['type-title-lg', 'type-emphasized-title-lg'],
|
||||
['type-title-md', 'type-emphasized-title-md'],
|
||||
['type-title-sm', 'type-emphasized-title-sm'],
|
||||
['type-body-lg', 'type-emphasized-body-lg'],
|
||||
['type-body-md', 'type-emphasized-body-md'],
|
||||
['type-body-sm', 'type-emphasized-body-sm'],
|
||||
['type-label-lg', 'type-emphasized-label-lg'],
|
||||
['type-label-md', 'type-emphasized-label-md'],
|
||||
['type-label-sm', 'type-emphasized-label-sm'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="type" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Type</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
M3's typescale in Google Sans Flex, each style regular and emphasized: <code>type-title-lg</code>, <code>type-emphasized-title-lg</code>.
|
||||
</p>
|
||||
|
||||
<div class="divide-y divide-divider rounded-corner-lg bg-surface-container">
|
||||
@foreach ($styles as [$regular, $emphasized])
|
||||
<div class="grid gap-2 p-4 md:grid-cols-[12rem_1fr_1fr] md:items-baseline">
|
||||
<code class="type-label-md text-on-surface-variant">{{ $regular }}</code>
|
||||
<p @class([$regular, 'truncate'])>Share files, safely</p>
|
||||
<p @class([$emphasized, 'truncate'])>Share files, safely</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user