Cut duplicated and speculative code across the package

An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 19:29:21 +02:00
co-authored by Claude Opus 5
parent 471d927e64
commit 247c596c3a
233 changed files with 16635 additions and 10579 deletions
+2 -2
View File
@@ -6,13 +6,13 @@
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser,
40px drawn with the 48px target M3 asks for (ACT-18).
40px drawn with the 48px target M3 asks for.
It is a `role="status"`, whatever its colour: an alert is usually part of the page as it
renders, and `role="alert"` is an assertive live region that some screen readers announce over
the page title on load — and that a Livewire morph re-announces. `assertive` opts into
`role="alert"` for the case the prop is named after: a notice put on screen in answer to
something the person just did (ACT-36). M3 publishes no banner; the nearest thing it does
something the person just did. M3 publishes no banner; the nearest thing it does
publish, the snackbar, says polite and never assertive.
Drawn by resources/css/components/alert.css from `data-md-alert` and `data-md-color`; the
+2 -2
View File
@@ -66,7 +66,7 @@
default => 'outline',
};
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-badge' => true,
'data-md-dot' => $dot ? true : null,
'data-md-variant' => $variant,
@@ -74,7 +74,7 @@
'data-md-floating' => $floating ? true : null,
'aria-label' => $label,
'aria-hidden' => $label === null && ! $status ? 'true' : null,
], fn ($value): bool => $value !== null));
]);
@endphp
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
@@ -36,7 +36,7 @@
The handle's wrapper is the drag target and M3's 48dp hit area: the button's own visual bar is
32×4px, and `md-touch-target` (foundation/interaction.css) extends it, while the wrapper's own
22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) gives the row its
M3-specified height regardless — 4 + 22 + 22 = 48px (C-01). The button renders the shared
M3-specified height regardless — 4 + 22 + 22 = 48px. The button renders the shared
`md-focus-ring` and `md-touch-target` classes rather than a hand-rolled ring: unlike the
datepicker's day, its own visible bar is not a smaller indicator drawn inside a bigger box the
classes cannot reach the box the classes draw is the button's whole hit area.
@@ -95,7 +95,7 @@
<div
x-data="{
...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}),
...materialBottomSheet({{ $presets }}),
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
}"
x-bind:data-md-open="open ? '' : null"
+1 -4
View File
@@ -129,10 +129,7 @@
// Read from the prop, not from `:disabled`: a spinner disables its button while the action
// runs, and that FAB is busy, not unavailable.
'data-md-unavailable' => $fab && $disabled ? true : null,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $isLink ? null : $type,
+3 -4
View File
@@ -33,8 +33,7 @@
lands on the card itself. Elevation is separate from the tint M3 gives each variant its own
per-state box-shadow (elevated 1dp rest 2dp hover 1dp focus/pressed; filled and outlined
0dp rest 1dp hover 0dp focus/pressed) so it is drawn directly on `[data-md-card]`, never
inside the shared class. Its corner does not move, because
M3 gives a card one shape (docs/audits/m3-alignment/containment.md, C-13, C-23).
inside the shared class. Its corner does not move, because M3 gives a card one shape.
`data-md-dragged` is M3's dragged card, row or not: the top of a card's elevation scale 8dp
elevated, 6dp filled and outlined under the shared class's own 16% dragged tint (it already
@@ -68,14 +67,14 @@
@endphp
<div
{{ $attributes->merge(array_filter([
{{ $attributes->merge([
'data-md-card' => $variant,
'data-md-list-row' => $actionable ? true : null,
'data-md-list-actionable' => $actionable ? true : null,
'tabindex' => $actionable ? '0' : null,
'role' => $role,
'aria-label' => $actionable && filled($title) ? $title : null,
], fn ($value): bool => $value !== null))->class([
])->class([
// Every card: the application sets `data-md-dragged` from script, after this renders.
'md-state-layer',
'md-focus-ring' => $isRow,
@@ -11,7 +11,7 @@
for under text on an image, and the scrim is `--md-sys-color-scrim` black in every scheme,
theme and contrast level so its ink is the one absolute the theme keeps, `white`; a role
(`inverse-on-surface`) is dark ink in a dark scheme and fails on the scrim there
(docs/audits/m3-alignment/containment.md, C-25, deliberately kept).
(deliberately kept).
A focusable `group` with `aria-roledescription="slide"`, named "n of m" by the carousel
around it. M3 puts the tab stop on the item, not on the row: Tab reaches the first item, the
@@ -140,7 +140,7 @@
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-carousel' => $layout,
'data-md-centered' => $layout === 'hero' && $centered ? true : null,
'data-md-padding' => (string) $paddingStart,
@@ -153,7 +153,7 @@
$multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
]);
@endphp
<div x-data="materialCarousel" {{ $attributes }}>
@@ -29,9 +29,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
@endphp
<div data-md-checkbox {{ $attributes->only(['class', 'style', 'wire:key']) }}>
@@ -34,9 +34,7 @@
@php
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$messages = filled($errorField) && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorField), $errors->get($errorField.'.*')])))
: [];
$messages = \NoNameWeb\LivewireMaterial\Support\Field::messages($errors ?? null, filled($errorField) ? $errorField : null, wildcard: true);
$describedBy = $messages !== [] || filled($hint) ? "material-chip-set-{$key}-hint" : null;
@endphp
+14 -14
View File
@@ -127,18 +127,15 @@
]));
$action = $attributes->except($outer)
->merge(array_filter([
->merge([
'data-md-chip-action' => '',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $actionTag === 'button' ? 'button' : null,
'disabled' => $actionTag === 'button' && $disabled ? true : null,
'aria-pressed' => $actionTag === 'button' && $selected !== null ? ($selected ? 'true' : 'false') : null,
], fn ($attribute): bool => $attribute !== null));
]);
} else {
$tag = match (true) {
$checkbox => 'label',
@@ -146,22 +143,21 @@
default => 'button',
};
$control = $checkbox
? $attributes->except($outer)->merge(array_filter([
? $attributes->except($outer)->merge([
'type' => 'checkbox',
'name' => $name,
'value' => $value,
'checked' => $checked ? true : null,
'disabled' => $disabled ? true : null,
'class' => 'md-visually-hidden',
], fn ($attribute): bool => $attribute !== null))
])
: null;
// $state's own filtering (above) drops a null 'style' here same as everywhere else, so this
// one is not wrapped again.
$container = ($checkbox ? $attributes->only($outer) : $attributes)
->merge($state(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
->merge($state([
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $tag === 'button' ? 'button' : null,
@@ -170,7 +166,11 @@
'value' => $tag === 'button' ? $value : null,
'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($attribute): bool => $attribute !== null)));
]))
// The chip itself is the control here (never for an input chip, whose action and remove
// button draw their own layers): the foundation's state layer and 48px target, its
// `:has(:focus-visible)` and `[data-md-disabled]` extras kept in chip.css alone.
->class(['md-state-layer', 'md-touch-target']);
}
@endphp
+4 -9
View File
@@ -45,11 +45,9 @@
@php
$model = $attributes->wire('model')->value() ?: null;
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey, wildcard: true);
$choices = collect($options)->map(fn ($option): array => [
'value' => data_get($option, $optionValue),
'label' => (string) data_get($option, $optionLabel),
@@ -58,10 +56,7 @@
$single = $single || $searchable;
// Rendered as the bound property already says, so nothing flips once Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $field::bound($model, $value);
$isSelected = fn ($candidate): bool => $single
? $current !== null && (string) $current === (string) $candidate
: in_array((string) $candidate, array_map('strval', array_filter((array) $current, 'is_scalar')), true);
@@ -35,12 +35,8 @@
@php
$model = $attributes->wire('model')->value() ?: null;
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
$expanded = (bool) $open;
$expanded = (bool) \NoNameWeb\LivewireMaterial\Support\Field::bound($model, $open);
$variant = $variant === 'filled' ? 'filled' : 'plain';
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$expanded = (bool) data_get($component, $model);
}
@endphp
<details
+40 -69
View File
@@ -76,11 +76,9 @@
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey, wildcard: true);
$locale = str_replace('_', '-', app()->getLocale());
$weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null;
$format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3
@@ -100,10 +98,7 @@
};
// Rendered as the bound property already says, so the field does not change once Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $field::bound($model, $value);
$current = $range
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
: $toIso($current);
@@ -343,45 +338,32 @@
</div>
<div data-md-datepicker-nav data-md-docked x-show="presentation === 'docked'">
<span data-md-datepicker-stepper>
<span data-md-datepicker-arrows x-bind:data-md-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
{{-- Months and years: the same stepper either side of a menu button, a step of
1 or 12 apart. --}}
@foreach ([
['unit' => 'months', 'step' => 1, 'label' => 'monthLabel', 'previous' => __('Previous month'), 'next' => __('Next month'), 'switch' => __('Switch to selecting a month')],
['unit' => 'years', 'step' => 12, 'label' => 'yearLabel', 'previous' => __('Previous year'), 'next' => __('Next year'), 'switch' => __('Switch to selecting a year')],
] as $stepper)
<span data-md-datepicker-stepper>
<span data-md-datepicker-arrows x-bind:data-md-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_left" :tooltip="$stepper['previous']" x-on:click="step(-{{ $stepper['step'] }})" x-bind:disabled="view !== 'days' || ! canStep(-{{ $stepper['step'] }})" />
</span>
<button
type="button"
class="md-state-layer md-focus-ring"
data-md-datepicker-menu-button="{{ $stepper['unit'] }}"
x-on:click="toggleView('{{ $stepper['unit'] }}')"
x-bind:aria-expanded="(view === '{{ $stepper['unit'] }}').toString()"
x-bind:aria-label="{{ $stepper['label'] }} + ', ' + @js($stepper['switch'])"
>
<span x-text="{{ $stepper['label'] }}"></span>
<x-livewire-material::icon name="arrow_drop_down" size="18" data-md-datepicker-menu-arrow />
</button>
<span data-md-datepicker-arrows x-bind:data-md-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_right" :tooltip="$stepper['next']" x-on:click="step({{ $stepper['step'] }})" x-bind:disabled="view !== 'days' || ! canStep({{ $stepper['step'] }})" />
</span>
</span>
<button
type="button"
class="md-state-layer md-focus-ring"
data-md-datepicker-menu-button="months"
x-on:click="toggleView('months')"
x-bind:aria-expanded="(view === 'months').toString()"
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
>
<span x-text="monthLabel"></span>
<x-livewire-material::icon name="arrow_drop_down" size="18" data-md-datepicker-menu-arrow />
</button>
<span data-md-datepicker-arrows x-bind:data-md-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
</span>
</span>
<span data-md-datepicker-stepper>
<span data-md-datepicker-arrows x-bind:data-md-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
</span>
<button
type="button"
class="md-state-layer md-focus-ring"
data-md-datepicker-menu-button="years"
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="yearLabel"></span>
<x-livewire-material::icon name="arrow_drop_down" size="18" data-md-datepicker-menu-arrow />
</button>
<span data-md-datepicker-arrows x-bind:data-md-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
</span>
</span>
@endforeach
</div>
<table role="grid" aria-labelledby="{{ $id }}-month" x-show="view === 'days'" x-on:keydown="gridKey($event)" data-md-datepicker-grid>
@@ -479,40 +461,29 @@
<div x-show="typing" data-md-datepicker-entry>
<div @if ($range) data-md-range @endif data-md-datepicker-entry-fields>
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry"
type="text"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-ref="entry"
x-model="entry"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
data-md-field-control
/>
</x-livewire-material::field>
@if ($range)
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
@foreach ($range ? [
['id' => "{$id}-entry", 'label' => __('Start date'), 'model' => 'entry', 'ref' => true],
['id' => "{$id}-entry-end", 'label' => __('End date'), 'model' => 'entryEnd', 'ref' => false],
] : [
['id' => "{$id}-entry", 'label' => __('Date'), 'model' => 'entry', 'ref' => true],
] as $entry)
<x-livewire-material::field id="{{ $entry['id'] }}" :label="$entry['label']" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry-end"
id="{{ $entry['id'] }}"
type="text"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-model="entryEnd"
@if ($entry['ref']) x-ref="entry" @endif
x-model="{{ $entry['model'] }}"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
data-md-field-control
/>
</x-livewire-material::field>
@endif
@endforeach
</div>
<div id="{{ $id }}-entry-support" data-md-datepicker-support>
+4 -4
View File
@@ -32,11 +32,11 @@
@endphp
@if (filled($text) && ! $vertical)
<div {{ $attributes->merge(array_filter([
<div {{ $attributes->merge([
'data-md-divider-heading' => true,
'data-md-inset' => $inset ? true : null,
'data-md-middle' => $middle ? true : null,
], fn ($value): bool => $value !== null)) }}>
]) }}>
<span data-md-divider-text>{{ $text }}</span>
<div
data-md-divider
@@ -49,9 +49,9 @@
data-md-divider
data-md-orientation="{{ $orientation }}"
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="{{ $orientation }}" @endif
{{ $attributes->merge(array_filter([
{{ $attributes->merge([
'data-md-inset' => $inset && ! $vertical ? true : null,
'data-md-middle' => $middle ? true : null,
], fn ($value): bool => $value !== null)) }}
]) }}
></div>
@endif
+3 -7
View File
@@ -50,7 +50,7 @@
M3 **requires** a close affordance on a side sheet — without one nobody can predict the
sheet's open/close flow or tell whether it is transient or permanent
(docs/reference/m3/components-actions-communication-containment.md § Side sheets
Accessibility, C-04) so `with-close-button` is on by default, and
Accessibility) so `with-close-button` is on by default, and
`:with-close-button="false"` is ignored where nothing else closes the sheet: Escape off, the
scrim off, or `standard`, which has neither from `expanded`.
@@ -88,11 +88,7 @@
// A standard sheet bound to Livewire is drawn open from the first paint when its property is
// (truthy as Alpine reads it), so it stands in the row before the script starts rather than
// growing into it; drawer.css keeps any other standard sheet out of the row until then.
$startsOpen = false;
if ($standard && $model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$startsOpen = ! in_array(data_get($component, $model), [null, false, 0, 0.0, ''], true);
}
$startsOpen = $standard && ! in_array(\NoNameWeb\LivewireMaterial\Support\Field::bound($model, false), [null, false, 0, 0.0, ''], true);
// M3 requires a close affordance; the prop can only ever add one, never take away the last
// way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and
@@ -231,7 +227,7 @@
@isset($actions)
{{-- The side-sheet specs table: bottom actions 72dp tall, 16dp above them, 24dp below
(the sheet's own padding), aligned **left** a dialog's are trailing-aligned, and
M3 specifies the two differently (C-10). --}}
M3 specifies the two differently. --}}
<div data-md-drawer-actions>{{ $actions }}</div>
@endisset
</aside>
@@ -22,17 +22,14 @@
$tag = $isLink ? 'a' : 'button';
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-fab-menu-item' => true,
'data-md-color' => $color,
'role' => 'menuitem',
'tabindex' => '-1',
'type' => $isLink ? null : 'button',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
], fn ($value): bool => $value !== null))->class(['md-state-layer', 'md-focus-ring']);
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes),
])->class(['md-state-layer', 'md-focus-ring']);
@endphp
<{{ $tag }} {{ $attributes }}>
@@ -10,7 +10,7 @@
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
round close button in the colour itself while the list is open above it, end-aligned; the
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
and the menu for screen readers, and defaults to "Toggle menu" (ACT-21) — the icons inside
and the menu for screen readers, and defaults to "Toggle menu" — the icons inside
the button are aria-hidden, so without it the trigger has no name at all. Give the items the
same `color`. Like `<x-menu>`'s, the list is keyed for Livewire, so it stays open through a
render of the component around it.
@@ -20,8 +20,8 @@
@media (width >= 1200px) { .my-fab-menu { inset-inline-end: 24px; bottom: 24px } }
A window too short for the list scrolls it, behind the close button, which stays fixed
(ACT-32); the items rise into place as it opens and sink back as it closes (ACT-33).
A window too short for the list scrolls it, behind the close button, which stays fixed;
the items rise into place as it opens and sink back as it closes.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px close button, full
corner when open, 20px close icon, 8px to the first item, 56px items 4px apart.
+1 -4
View File
@@ -64,10 +64,7 @@
'data-md-variant' => $variant,
'data-md-extended' => $extended ? true : null,
'data-md-collapse-on-scroll' => $collapsing ? true : null,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes),
'type' => $isLink ? null : $type,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-md-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
+2 -2
View File
@@ -39,11 +39,11 @@
$element = $layout::element($as);
$minItem = $layout::length($minItem) ?? '240px';
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-feed' => true,
'data-md-gap' => $layout::spacing($gap),
'style' => "--md-min-item: {$minItem};",
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
+3 -5
View File
@@ -23,11 +23,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey, wildcard: true);
@endphp
<x-livewire-material::field :$id :$label :$hint :$messages :$variant :$full floated :class="$attributes->get('class')" :style="$attributes->get('style')" data-md-file>
+2 -2
View File
@@ -37,13 +37,13 @@
$style = $layout::columnStyle($layout::columns($columns)).($minItem !== null ? " --md-min-item: {$minItem};" : '');
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-grid' => true,
'data-md-gap' => $layout::spacing($gap),
'data-md-min-item' => $minItem !== null ? true : null,
'data-md-columns' => $minItem !== null && $columns !== null ? true : null,
'style' => $style,
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
+6 -5
View File
@@ -49,9 +49,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model;
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$variant = in_array($variant, ['tonal', 'filled', 'outlined'], true) ? $variant : 'tonal';
// M3's "Default shape | Round, square": a square group's ends take the corner its inner edges
@@ -63,14 +63,14 @@
// Below `medium` a segment draws under 48px, so it needs the foundation's touch target too.
$segmentNeedsTouchTarget = in_array($size, ['xs', 'sm'], true);
$root = $attributes->only(['class', 'style', 'wire:key'])->merge(array_filter([
$root = $attributes->only(['class', 'style', 'wire:key'])->merge([
'data-md-group' => true,
'data-md-size' => $size,
'data-md-variant' => $variant,
'data-md-shape' => $shape,
'data-md-multiple' => $multiple ? true : null,
'data-md-inline' => $inline ? true : null,
], fn ($value): bool => $value !== null));
]);
@endphp
<fieldset {{ $root }}>
@@ -82,6 +82,7 @@
@foreach ($options as $option)
<label data-md-group-segment class="md-state-layer @if ($segmentNeedsTouchTarget) md-touch-target @endif">
<input
class="md-visually-hidden"
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="{{ $multiple ? 'checkbox' : 'radio' }}"
name="{{ $multiple ? $name.'[]' : $name }}"
+3 -3
View File
@@ -38,9 +38,9 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$max = $counter ? $attributes->get('maxlength') : null;
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@@ -58,21 +58,17 @@
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
$element = $layout::element($as);
$model = $attributes->wire('model')->value() ?: null;
$initial = $selected;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$initial = data_get($component, $model);
}
$initial = \NoNameWeb\LivewireMaterial\Support\Field::bound($model, $selected);
$chosen = $initial !== null && $initial !== false && $initial !== '';
$wire = $attributes->wire('model');
$detailSlot = $detail ?? null;
$ownBack = $detailSlot !== null && str_contains((string) $detailSlot, 'data-md-list-detail-back');
$attributes = $attributes->whereDoesntStartWith('wire:model')->merge(array_filter([
$attributes = $attributes->whereDoesntStartWith('wire:model')->merge([
'data-md-list-detail' => true,
'data-md-selected' => $chosen ? true : null,
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }}
@@ -26,12 +26,12 @@
])
@php
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-menu-group' => true,
'data-md-gap' => $gap ? true : null,
'role' => 'group',
'aria-label' => filled($label) ? $label : null,
], fn ($value): bool => $value !== null));
]);
@endphp
<div {{ $attributes }}>
@@ -34,10 +34,10 @@
the icon over the ink this file gives it, because a caller's unlayered class always outranks
the package's layer except a disabled item's icon, which stays disabled regardless.
48px tall (M3's published row height wins over SegmentedMenuTokens' own 44dp, ACT-28),
body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive spacing,
ACT-11), 4px corners that open to 12px at the ends of the list the corner on the spatial
spring and the colour on the effects one, apart (ACT-19).
48px tall (M3's published row height wins over SegmentedMenuTokens' own 44dp),
body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive spacing),
4px corners that open to 12px at the ends of the list the corner on the spatial
spring and the colour on the effects one, apart.
Drawn by resources/css/components/menu-item.css from `data-md-menu-item`,
`data-md-description` and the item's aria-* state. --}}
@@ -84,10 +84,7 @@
'aria-controls' => $submenu ? "material-submenu-{$key}" : null,
'x-on:click' => $submenu ? "toggle('first')" : null,
'type' => $isLink ? null : 'button',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
// Opening a submenu is not choosing anything: the outer menu stays where it was.
'data-md-keep-open' => $keepOpen || $submenu ? true : null,
], fn ($value): bool => $value !== null))->class(['md-state-layer', 'md-focus-ring']);
+17 -120
View File
@@ -1,28 +1,12 @@
{{-- An M3 Expressive menu: a list of actions that opens from a trigger.
{{-- An M3 Expressive menu: a list of actions that opens from a trigger. Props, slots, `filter`
and `sheet-at-compact` are documented in SKILL.md; this is the mechanics behind them.
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" wire:click="copy" />
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
</x-menu>
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
when there is no room — to the other side, the other end, or both, so a menu on a FAB in a
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
The anchor name is rendered on the wrapper around the trigger slot, the only element the
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
box where the page put it, and the menu opened there.
The list is a `popover="auto"` in the top layer, placed by CSS anchor positioning at
`position`, flipping when there is no room. The anchor name is rendered on the wrapper around
the trigger slot, the only element the server can name, and resources/js/menu.js moves it
onto the menu button itself: a trigger that is `position: fixed` (`<x-button fab>` on a
phone) leaves the wrapper behind as an empty box where the page put it, and the menu opened
there.
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
a Livewire morph matches it by before the id, so a render of the component around an open
@@ -32,54 +16,16 @@
of the loop iteration around it, which would give every child component after the menu the
same key.
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
top layer where nothing can reach it (ACT-04): 288px at most, and less on a short window. The
arrow keys, Home, End and typeahead bring the item they move to into view, and a disabled
item is among them: M3 keeps one reachable so a person can find out that it exists.
It opens by growing out of the corner nearest its trigger and fades as it goes, which is the
transition M3 asks to tie a menu to what opened it (ACT-26).
`filter` is M3's menu as a filtering surface ("autocomplete"): a text field at the top of the
list, which stays put while the list scrolls under it, narrowing the items to those whose
label holds what has been typed in the browser, over the items already rendered, so nothing
is fetched and a `wire:click` stays where it was. `filter="Find a person"` names the field;
bare `filter` calls it "Filter". The field, not the list, holds the focus, so a person can
type and steer at once: the arrow keys, Home and End move a highlighted row and say which one
through `aria-activedescendant`, and Enter chooses it the APG combobox keyboard, the same
one `<x-choices searchable>` uses. The list around it stays a `role="menu"` of its own inside
the popover, because a text field is not a thing a menu may contain.
`sheet-at-compact` is M3's adaptive menu: "at compact breakpoints, consider swapping a menu
for a bottom sheet (more room for items/longer labels); at medium/expanded breakpoints, menus
work well in context" (docs/reference/m3/components-actions-communication-containment.md
§ Menus → Behaviour; foundations.md § Layout gives compact the bottom sheet for supplemental
selection and actions, and medium up the menu). Below `medium` (600px) the trigger opens the
items in a modal `<x-bottom-sheet>` — M3's "alternative to inline menus … on mobile", closed
by choosing an item, the scrim, a swipe down or Escape (§ Bottom sheets Behaviour) and
from `medium` it opens the popover. The trigger says which: `aria-haspopup="dialog"` and the
sheet's id while the window is compact, `menu` and the popover's otherwise, `aria-expanded`
in both. The slot is written once and drawn twice, in the popover and in the sheet, so a
Livewire render patches both copies and a chosen item shows chosen in either. The sheet is
teleported to the end of <body>: a menu in a sticky app bar or a toolbar would otherwise cover
the window only inside that bar's stacking context, under the navigation bar.
Inside the sheet the items keep their roles and the keyboard above — arrows, Home, End, a
letter; Escape closes the sheet and Tab does too, and either returns focus to the trigger —
and choosing one closes the sheet as it closes the popover. A submenu opens in place under
its item instead of beside it (M3 calls submenus "best suited to large screens"), and a
`filter` field stands at the top of the sheet. A window resized across 600px closes whichever
is open rather than leaving the other shown. The sheet is M3's own container
(surface-container-low) for a `vibrant` menu too, and is as tall as its items up to the
half screen M3 caps a modal sheet's first position at, scrolling inside past that. The
sheet's id is fixed and the lists are keyed, so a render keeps an open sheet open with its
focus where it was (below). Since the items exist twice, an `id` of the caller's or a nested
Livewire component among them would exist twice too: keep those out of a `sheet-at-compact`
menu.
For `sheet-at-compact`: the slot is written once and drawn twice, in the popover and in the
sheet, so a Livewire render patches both copies and a chosen item shows chosen in either. The
sheet is teleported to the end of `<body>`, so a menu in a sticky app bar or a toolbar still
covers the whole window rather than only that bar's own stacking context. Since the items
exist twice, an `id` of the caller's or a nested Livewire component among them would exist
twice too: keep those out of a `sheet-at-compact` menu.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0).
androidx Compose Material 3 (Apache-2.0). A menu longer than the window scrolls at 288px.
Drawn by resources/css/components/menu.css, which also draws the dropdown a form's own lists
wear — `<x-select>`'s exposed picker and `<x-choices searchable>`'s listbox — so a menu and a
@@ -130,32 +76,7 @@
x-on:click="activate($event)"
>
@if ($filtering)
<div data-md-menu-filter>
<x-livewire-material::icon name="search" size="20" />
<input
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="material-menu-{{ $key }}-list"
aria-label="{{ $filterLabel }}"
placeholder="{{ $filterLabel }}"
x-on:input="refine()"
x-on:keydown.stop="search($event)"
/>
</div>
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
'id' => "material-menu-{$key}-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} data-md-menu-list>
{{ $slot }}
<p data-md-menu-empty hidden>{{ __('Nothing matches') }}</p>
</div>
@include('livewire-material::partials.menu-filter', ['idSuffix' => '', 'wireKey' => null, 'delegated' => false])
@else
{{ $slot }}
@endif
@@ -190,31 +111,7 @@
'wire:key' => 'material-menu-sheet-menu',
'id' => "material-menu-{$key}-sheet-menu",
]) }} data-md-menu-sheet>
<div data-md-menu-filter>
<x-livewire-material::icon name="search" size="20" />
<input
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="material-menu-{{ $key }}-sheet-menu-list"
aria-label="{{ $filterLabel }}"
placeholder="{{ $filterLabel }}"
/>
</div>
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
'wire:key' => 'material-menu-sheet-list',
'id' => "material-menu-{$key}-sheet-menu-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} data-md-menu-list>
{{ $slot }}
<p data-md-menu-empty hidden>{{ __('Nothing matches') }}</p>
</div>
@include('livewire-material::partials.menu-filter', ['idSuffix' => '-sheet-menu', 'wireKey' => 'material-menu-sheet-list', 'delegated' => true])
</div>
@else
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
+5 -5
View File
@@ -26,13 +26,13 @@
above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that
holds a form take the whole screen on a compact window (below `medium`, 600px M3 uses
full-screen dialogs "only in compact breakpoints", FullScreenDialogTokens), with a 56px
close-and-title bar clear of the notch (docs/audits/m3-alignment/containment.md, C-17).
close-and-title bar clear of the notch.
`persistent` ignores Escape and the scrim, for a dialog that must be answered. It opens on the
fast spatial spring and closes at once, as M3's do.
"Dialog content generally shouldn't scroll; if it must, the title stays pinned at the top and
the buttons at the bottom" (docs/reference/m3/components-actions-communication-containment.md
§ Dialogs → Behaviour, C-06): the header and the action row are their own rows of the flex
§ Dialogs → Behaviour): the header and the action row are their own rows of the flex
column and only the body between them scrolls, each with the 24dp padding the box used to
carry.
@@ -53,7 +53,7 @@
around the slot, so content a Livewire render adds counts, and marks the `<dialog>` with
`data-md-overflow-top`/`data-md-overflow-bottom`.
`alert` is M3's "on web, basic dialogs should have the alert dialog role" (C-20): it sets
`alert` is M3's "on web, basic dialogs should have the alert dialog role": it sets
`role="alertdialog"` and points `aria-describedby` at the body, for the dialog that
interrupts to say something important. It is opt-in, because ARIA-APG keeps `alertdialog`
for exactly that and a form dialog would over-announce with it. A `subtitle` is always the
@@ -106,11 +106,11 @@
@if ($alert) role="alertdialog" @endif
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->merge(array_filter([
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->merge([
'id' => $id,
'data-md-modal' => true,
'data-md-fullscreen' => $fullscreen ? true : null,
], fn ($value): bool => $value !== null)) }}
]) }}
>
<div data-md-modal-box @if (filled($boxClass)) class="{{ $boxClass }}" @endif>
@if ($fullscreen)
@@ -34,10 +34,7 @@
$spoken = $badgeLabel ?? ($count ? $badge : null);
$attributes = $attributes->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null,
'data-md-active' => $active ? true : null,
@@ -37,10 +37,7 @@
$spoken = $badgeLabel ?? ($count ? $badge : null);
$attributes = $attributes->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null,
'data-md-active' => $active ? true : null,
@@ -1,97 +1,19 @@
{{-- M3 Expressive's navigation rail: destinations down the start edge of a `medium` or wider
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width
pill). `mode`, props, slots and `$store.rail` are documented in SKILL.md.
<x-row align="stretch" style="min-height: 100dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="md-type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab label="Compose" icon="edit" />
</x-slot:header>
Anything else inside the rail queries `--md-navigation-rail-value` (SKILL.md has the pattern
and an example); the rail's own two rules (resources/css/components/navigation-rail.css) are
what publish it, so every other rule just queries it instead of copying the rail's own
conditions. tests/Feature/Components/NavigationRailTest.php checks both halves.
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
</x-slot:footer>
</x-navigation-rail>
<main style="flex: 1; min-width: 0">…</main>
</x-row>
`mode` says what decides its width:
- `collapsed` — always collapsed; `expanded` — always expanded.
- `collapsible` (the default) — the visitor's choice: expanded until the menu button collapses
it. The choice is `$store.rail`, remembered in localStorage and applied by <x-theme-script>
before the first paint (<html data-rail>), so the rail never paints wide and snaps shut.
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` — what `<x-scaffold>` uses, one rail per M3 window size class: on a compact
window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
at `medium` (600839) collapsed in the layout, opening as a modal; at `expanded` (8401199)
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
that choice in both standard bands.
Slots: `brand` beside the menu button, only while expanded; `header` under it — one
`<x-fab label="…" icon="…">`, which the rail morphs: the label's width springs open and shut
with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for
the other, and its label names it at both widths. It also rests flat, because M3 puts a FAB
nested in another component at elevation 0, not the 3 a standalone one has. Then the
destinations in the default slot, which alone scroll when the window is too short, and
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
the scroller's edge.
Anything else inside takes both shapes from the rail's value: the rail publishes M3's two
(Compose's WideNavigationRailValue) as `--md-navigation-rail-value`, `collapsed` or
`expanded`, from the first paint and in step with the rail's own items, and every descendant
inherits it, so an application's CSS asks a style query instead of repeating the conditions:
@container style(--md-navigation-rail-value: collapsed) {
.account-summary { display: none; }
}
A rail open over a scrim reads `expanded`; outside a rail the property is unset and neither
value matches. Style queries on a custom property need Chrome 111, Safari 18 or Firefox 151.
The package's own rules write the conditions out (resources/css/components/navigation-rail.css
lists them), and tests/Feature/Components/NavigationRailTest.php keeps each copy to the same
conditions. Nothing that shows while collapsed may be wider than 96px.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
between M3's 220 and 360dp) or the word `narrow`, M3's other *collapsed* width
(NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the
items are their icons alone because no label fits under a 56px indicator at that width; the
labels stay in the accessibility tree, since they are what name the destinations, and a
narrow rail expands to the default 256px; `align` is `top` (the default) or `center`, which
puts the destinations at the rail's vertical centre M3 prefers that on a tablet, for reach
while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3
asks; more destinations than fit go back to the top rather than out of reach above the
scroller; `hide-when-collapsed` is M3's other expanded behaviour, for a `collapsible` or
`adaptive` rail: collapsing it takes it out of the layout altogether instead of narrowing it
to 96px, and it comes back expanded over a scrim when something calls `$store.rail.show()` —
a menu button in the app bar, which is the only way back, so put one there. Its own menu
button then docks it into the layout again. Not below `medium` for a collapsible rail, nor at
`medium` for an adaptive one: there it is the window and not the visitor that collapses a
rail, and M3's collapsed rail may never hide; `menu` shows the menu button (by default for `collapsible`,
`modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page
is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill`
(`false`) drops the container colour for a transparent rail over the page's own background,
which M3 allows as long as the items keep a 3:1 contrast against what is behind them. A rail
open over a scrim keeps its fill and drops the divider whatever those say: it is a surface
over the page then. As it closes it keeps `data-md-closing` (`sheet` while the panel slides off
the window, `scrim` while only the scrim fades) until the exit has run, which is what holds it
on screen in Firefox, where `display` cannot transition (resources/js/navigation.js). The rail
does not scroll with the page: in a flex row it sticks to the top of the viewport, as tall as
the viewport at most.
As the rail closes it keeps `data-md-closing` (`sheet` while the panel slides off the window,
`scrim` while only the scrim fades) until the exit has run: Firefox cannot transition
`display`, so this is what holds a closing rail on screen there (resources/js/navigation.js).
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
WideNavigationRail.kt under
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and WideNavigationRail.kt
— surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
the header and 40px under it, 4px between collapsed items. The styles are
resources/css/components/navigation-rail.css; the behaviour resources/js/navigation.js. --}}
+2 -2
View File
@@ -51,10 +51,10 @@
$backAction = $back === true;
$bar = filled($title) || isset($actions) || isset($leading) || $backLink !== null || $backAction;
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-pane' => true,
'data-md-width' => $layout::choice($width, ['full', 'narrow', 'medium', 'wide']),
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>
@@ -23,9 +23,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp
@@ -136,7 +136,7 @@
$arcs[] = ['d' => $arc($start, $sweep), 'active' => true];
}
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'role' => $decorative ? null : 'progressbar',
'aria-label' => $label,
'aria-hidden' => $decorative ? 'true' : null,
@@ -155,7 +155,7 @@
'data-md-circular' => $circular ? true : null,
'data-md-wavy' => $wavy ? true : null,
'data-md-thick' => $thick ? true : null,
], fn ($attribute): bool => $attribute !== null));
]);
@endphp
<span {{ $attributes }}>
+3 -3
View File
@@ -29,9 +29,9 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
@endphp
<fieldset
@@ -17,11 +17,11 @@
element on the page rather than one the morph took away.
resources/js/rich-tooltip.js points the trigger at the bubble with `aria-describedby`, and a
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded` (ACT-22): the
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded`: the
explanation is the whole point of a rich tooltip, and without the association a screen reader
reads only the trigger's own label. The trigger arrives in a slot, so only script can find the
element in it that takes the focus. It stands for 1.5s after the pointer or focus leaves,
M3's transient tooltip timing (ACT-25), unless `persistent` keeps it open until dismissed.
M3's transient tooltip timing, unless `persistent` keeps it open until dismissed.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. Drawn by
+2 -2
View File
@@ -31,14 +31,14 @@
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
$element = $layout::element($as);
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-row' => true,
'data-md-gap' => $layout::spacing($gap),
'data-md-align' => $layout::choice($align, ['center', 'start', 'end', 'stretch', 'baseline']),
'data-md-justify' => $layout::choice($justify, ['start', 'center', 'end', 'between']),
'data-md-wrap' => $wrap ? true : null,
'data-md-stack-below' => $layout::edge($stackBelow),
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
+16 -98
View File
@@ -1,108 +1,26 @@
{{-- The scaffold: M3's structure of bars, rails and panes around the page, and the FAB, with
navigation that changes shape with the window. M3 and Compose call it the scaffold
(docs/reference/m3/foundations.md § Layout → Scaffold); before 2.0.0 it was `<x-app-shell>`.
navigation that changes shape with the window (docs/reference/m3/foundations.md § Layout →
Scaffold). Before 2.0.0 it was `<x-app-shell>`. Props, slots and the breakpoint table are in
SKILL.md — this is the WHY behind the wiring, not the API.
<x-scaffold :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="/" wire:navigate class="md-type-title-lg">SealShare</a></x-slot:brand>
<x-slot:top>…the page's app bar…</x-slot:top>
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
`actions`' collapsed-column layout queries the rail's published `--md-navigation-rail-value`
rather than a breakpoint of its own, so `resources/css/layout/scaffold.css` stays in step with
the rail's conditions without repeating them.
…the page…
</x-scaffold>
`--md-layout-margin: 0px` on the content region (resources/css/layout/scaffold.css) is read by
layout/pane.css: a pane or canonical layout inside the scaffold draws no margin of its own
because it can see it is already in a margined region.
The navigation is M3's per breakpoint (docs/reference/m3/foundations.md § Layout and
foundations-supplement.md § Breakpoints), and only those four numbers:
- **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`,
pinned to the bottom. Everything else is in the modal rail, which slides in when something
calls `$store.rail.show()` — put a menu button in the app bar for it, hidden from `medium`:
`<x-stack as="span" hide-from="medium"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></x-stack>`.
- **Medium** (600839): the collapsed rail in the layout, 96px, and no bar; its menu button
opens it expanded over a scrim, since 256px beside the page would leave the page too little.
- **Expanded** (8401199): a standard rail — in the layout, nothing covered — collapsed until
the menu button expands it in place.
- **Large and extra-large** (from 1200): the same standard rail, expanded to begin with
(`rail.default`), which is what M3 prefers once there is room.
From `expanded` the choice the menu button makes is remembered and applied before the first
paint (`$store.rail`, <x-theme-script>), so the rail never paints one width and snaps to the
other. A visitor who has chosen keeps that choice in both bands; one who never has gets the
breakpoint's own default.
`--material-margin` is M3's window margin — 16px on a compact window, 24px from `medium` — and
the content region is padded with it, so a page inside the scaffold writes no gutters of its own.
Something meant to reach the window's edges opts out with `margin-inline: calc(var(--material-margin) * -1)`.
Panes are `<x-pane>`, and two side by side are M3's canonical layouts from `expanded`,
`<x-list-detail>` and `<x-supporting-pane>`; inside the content region they draw no margin of
their own (resources/css/layout/scaffold.css sets `--md-layout-margin` there).
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the page's; during a Livewire update request, the page the
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
the badge instead: "3 unread"),
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
`navigate` (`false` for a full page load instead of `wire:navigate`).
Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the
page M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button
while it is expanded), `rail-header` (under it: one `<x-fab label="…" icon="…">`, which the
rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the
rail: footer destinations, an account), `actions`
(a row of icon buttons at the very foot, stacked in a column once the rail collapses to its
narrow width the same selectors `navigation-rail.css` matches "collapsed" with, since
`resources/css/layout/scaffold.css` keys off the rail's own hooks rather than a breakpoint of
its own: a theme toggle, sign out), `top` (the page's own bar, above the page and *beside* the
rail), `fab` (the
page's floating action button, see below) and the page itself. The rail is one element at every width, so what is in it is also in the modal rail a
phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom
offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window
while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it;
`hide-rail-when-collapsed` is M3's immersive configuration from `expanded` the rail leaves
the layout when the menu button collapses it, rather than narrowing to 96px, so the page has
the whole window. The only way back is `$store.rail.show()`, so put a menu button in the app
bar at every width, not just below `medium`.
`banner` or `top` is a decision about what the bar belongs to: an application-wide bar one
search, one account menu, the same on every page spans the window and the rail starts under
it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or
the other, never both. A banner that pins itself to the top of the window says how tall it is
`style="--material-banner: 4rem"` on `<x-scaffold>` so the rail sticks under it instead
of behind it.
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the scaffold;
on a compact window it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar).
`fab` places an `<x-fab>` as Compose's Scaffold places its floating action button: fixed at the
bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`
(M3's margins, foundations-supplement.md § Breakpoints; the FAB's own adaptive placement is
the lower trailing corner, components-actions-communication-containment.md § FAB), above the
navigation bar and the bottom safe area, and lifted over a snackbar while one shows
M3: a snackbar appears above a FAB, never in front of or behind one. Compose raises the
snackbar over the FAB instead; here the snackbar host is one fixed element every page shares,
so the FAB is what moves (resources/css/layout/scaffold.css). Use it or `rail-header`'s FAB,
not both: M3 says not to show more than one FAB on a screen. It comes after the page's bar and
before the page in focus order, where M3 puts a FAB (§ FAB Accessibility: "the FAB should be
prioritized in the page's focus order").
`overflow-x: clip` on the content region, below `expanded` only, is the backstop under every
page: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
container and break every `position: sticky` inside it (an app bar, a list-detail pane). A wide
window never clips what overhangs on purpose.
The snackbar host (`<x-toast />`) is part of the scaffold, not a separate element an app adds:
Compose raises the snackbar over its FAB, but here the host is one fixed element every page
shares, so the FAB (`fab` slot) is what moves above it instead (resources/css/layout/scaffold.css);
both clear the bottom bar through `--material-bottom-bar` on a compact window (the bar's 64px,
the safe-area inset and `--material-bottom-extra`, 0px unless the application docks something
above the bar).
Nothing application-specific belongs in here: an app's destinations and chrome come in through
the props and slots. The caller's `class` and `style` land on the root, `data-md-scaffold`
itself the element `style="--material-banner: 4rem"` above sets a custom property on. --}}
itself — the element `style="--material-banner: 4rem"` (SKILL.md) sets a custom property on. --}}
@props([
'destinations' => [],
@@ -28,7 +28,7 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model ?: 'scheme';
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$messages = \NoNameWeb\LivewireMaterial\Support\Field::messages($errors ?? null, $errorKey);
$roles = ['primary', 'secondary', 'tertiary'];
// profile => level => theme => role => hex, for the dots. Every level is filled — a profile
@@ -63,6 +63,7 @@
@foreach ($profiles as $profile => $scheme)
<label data-md-scheme-picker-option="{{ $profile }}" class="md-state-layer">
<input
class="md-visually-hidden"
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="radio"
name="{{ $name }}"
+3 -3
View File
@@ -33,9 +33,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp
+2 -2
View File
@@ -24,11 +24,11 @@
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
$element = $layout::element($as);
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-stack' => true,
'data-md-gap' => $layout::spacing($gap),
'data-md-align' => $layout::choice($align, ['stretch', 'start', 'center', 'end']),
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
@@ -49,11 +49,11 @@
$sheet = $compact === 'sheet';
$body = 'material-supporting-pane-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-supporting-pane' => true,
'data-md-width' => $layout::choice($width, ['fixed', 'split'], 'fixed'),
'data-md-compact' => $compact,
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>
+2 -2
View File
@@ -33,7 +33,7 @@
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
$element = $layout::element($as);
$attributes = $attributes->merge(array_filter([
$attributes = $attributes->merge([
'data-md-surface' => true,
'data-md-level' => $layout::choice($level, [
'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low',
@@ -42,7 +42,7 @@
'data-md-padding' => $layout::spacing($padding),
'data-md-corner' => $layout::choice($corner, ['none', 'xs', 'sm', 'md', 'lg', 'lg-increased', 'xl', 'xl-increased', 'xxl', 'full']),
'data-md-outlined' => $outlined ? true : null,
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
] + $layout::visibility($hideBelow, $hideFrom));
@endphp
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
+19 -15
View File
@@ -3,10 +3,11 @@
The id and the `hidden` panels come from the server, not from Alpine: until Alpine boots, every
panel would otherwise be on screen at once — four `role="tabpanel"` regions for a screen reader
to walk — and the tab buttons' `aria-controls` would point at nothing (N-05). `<x-tabs>` renders
this slot before its own view, so it cannot hand the panel anything; the panel reads the
attributes written on `<x-tabs>` off the component stack (Blade's `@aware` mechanism) and
repeats the two lines tabs.blade.php runs on them. The two must agree, so change them together.
to walk — and the tab buttons' `aria-controls` would point at nothing. `<x-tabs>` renders
this slot before its own view, so it cannot hand the panel anything; the panel reads `tabs`,
`id` and `selected` off the component stack with `@aware` and rebuilds tabs.blade.php's id and
initial tab from them (Support\Field::bound for the initial, since `wire:model` is not a
`@props` value `@aware` can read). The two must agree, so change them together.
The panel renders `data-md-tab-panel`, drawn by resources/css/components/tabs.css. --}}
@@ -14,22 +15,25 @@
'name',
])
@php
$aware = fn (string $key, $default = null) => $__env->getConsumableComponentData($key, $default);
@aware([
'tabs' => [],
'id' => null,
'selected' => null,
])
$tabs = $aware('tabs', []);
// `wire:model` is a key, not a prop, and its modifiers are part of the key: the spellings that
// make sense on a tab set. An exotic one only means the panel falls back to the first tab.
@php
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
// `wire:model` is a key, not a prop, so `@aware` cannot read it; its modifiers are part of the
// key too — the spellings that make sense on a tab set. An exotic one only means the panel
// falls back to the first tab.
$model = collect(['wire:model', 'wire:model.live', 'wire:model.blur', 'wire:model.change', 'wire:model.lazy'])
->map(fn (string $key) => $aware($key))
->map(fn (string $key) => $__env->getConsumableComponentData($key))
->first(fn ($value): bool => filled($value));
$tabsId = $aware('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$tabsId = $id ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$initial = $aware('selected') ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null && filled(data_get($component, $model))) {
$initial = data_get($component, $model);
}
$initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
$initial = $field::bound($model, $initial, requireFilled: true);
@endphp
<div
+3 -5
View File
@@ -4,7 +4,7 @@
optional `icon`, `badge` and `disabled` — so it arrives with the page, never a moment after it.
The panels are `<x-tab name="…">` in the slot; every panel is rendered, so switching never waits
on the server, and each arrives with its id and with every panel but the chosen one already
hidden, so nothing flashes and no screen reader meets four panels at once (N-05).
hidden, so nothing flashes and no screen reader meets four panels at once.
The chosen tab is the Livewire property in `wire:model` (entangled; `wire:model.live` tells the
server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is
@@ -12,7 +12,7 @@
the label, in primary) or `secondary` (under the whole tab, for tabs inside a section);
`stacked` puts a primary tab's icon over its label (64px); `scrollable` lets tabs keep their
own width and scroll sideways instead of sharing the width equally, offset 52dp from the
leading edge so it reads as scrollable (N-10, resources/css/components/tabs.css). A fixed
leading edge so it reads as scrollable (resources/css/components/tabs.css). A fixed
tab's label too long for its share wraps to a second line and truncates there, the whole bar
growing to hold it; a scrollable tab keeps its label on one line (M3: "may wrap to a max
second line if needed with truncation, or use scrollable tabs to give longer titles more
@@ -39,9 +39,7 @@
$id = $attributes->get('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null && filled(data_get($component, $model))) {
$initial = data_get($component, $model);
}
$initial = \NoNameWeb\LivewireMaterial\Support\Field::bound($model, $initial, requireFilled: true);
@endphp
<div
@@ -25,9 +25,9 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $attributes->get('name'));
$messages = $field::messages($errors ?? null, $errorKey);
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$max = $counter ? $attributes->get('maxlength') : null;
@endphp
@@ -61,9 +61,9 @@
@php
$model = $attributes->wire('model')->value() ?: null;
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$field = \NoNameWeb\LivewireMaterial\Support\Field::class;
$errorKey = $field::key($model, $name);
$messages = $field::messages($errors ?? null, $errorKey);
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
@@ -76,11 +76,7 @@
$latest = $clock($max);
// Rendered as the bound property already says, so the field is not empty until Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $clock($current);
$current = $clock($field::bound($model, $value));
// The first frame writes the time as the browser will; ICU decides the hour cycle on both sides.
$display = '';
@@ -241,31 +237,7 @@
></button>
</div>
<div
data-md-timepicker-period
role="radiogroup"
aria-label="{{ __('Select AM or PM') }}"
x-show="! is24"
x-on:keydown.arrow-right.prevent="movePeriod($event)"
x-on:keydown.arrow-left.prevent="movePeriod($event)"
x-on:keydown.arrow-down.prevent="movePeriod($event)"
x-on:keydown.arrow-up.prevent="movePeriod($event)"
>
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-md-timepicker-period-option="{{ $period }}"
class="md-state-layer md-focus-ring"
role="radio"
aria-checked="false"
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:tabindex="({{ $pm ? '' : '! ' }}isPm) ? 0 : -1"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
@include('livewire-material::partials.timepicker-period')
</div>
<div
@@ -348,31 +320,7 @@
</div>
@endforeach
<div
data-md-timepicker-period
role="radiogroup"
aria-label="{{ __('Select AM or PM') }}"
x-show="! is24"
x-on:keydown.arrow-right.prevent="movePeriod($event)"
x-on:keydown.arrow-left.prevent="movePeriod($event)"
x-on:keydown.arrow-down.prevent="movePeriod($event)"
x-on:keydown.arrow-up.prevent="movePeriod($event)"
>
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-md-timepicker-period-option="{{ $period }}"
class="md-state-layer md-focus-ring"
role="radio"
aria-checked="false"
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:tabindex="({{ $pm ? '' : '! ' }}isPm) ? 0 : -1"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
@include('livewire-material::partials.timepicker-period')
</div>
<p data-md-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
+10 -47
View File
@@ -1,57 +1,20 @@
{{-- The snackbar host: shows every toast, one at a time. Put it once in each layout, near the end
of <body>:
<x-toast />
It shows every `toast` browser event what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
dispatches from a Livewire component and for `window.materialToast(title, options)` from
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 410s),
paused while the pointer or focus is on it. A toast with an `action` has no timeout at all,
as M3 requires it waits to be read and acted on unless the caller writes a `timeout` out.
A toast with an action or no timeout gets a close button. Pressing the action closes the
snackbar, calls `handler` and dispatches `event` (a name) on `window`; both may be given.
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
{{-- The snackbar host: shows every toast, one at a time, near the end of `<body>`. `materialToast()`,
`type`, `action`, `sticky`, `description`, the keyboard and the `data-md-toast-*` hooks are
documented in SKILL.md.
The live region is the host itself, not the snackbar: a region must be in the page before its
contents change for a screen reader to announce them reliably, and the snackbar comes and
goes (ACT-02). It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar
(never assertive); a `type` picks the region's role — `alert` for an error or a warning,
goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar
(never assertive); `type` picks the region's `role` — `alert` for an error or a warning,
`status` otherwise — and the explicit `aria-live` keeps even those polite.
Hooks for tests and styling: `data-md-toast` on the host, `data-md-toast-snackbar` on the
snackbar on screen, `data-md-toast-action` on its action button and `data-md-toast-dismiss`
on its close button. Drawn by resources/css/components/toast.css.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
`@persist` keeps the host across `wire:navigate`, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives.
There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon
feels necessary", ACT-20), and both lines of the message are plain inverse-on-surface — M3
gives the supporting text no fourth colour and tells the two lines apart by position. The
40px action and close buttons reach M3's 48px target without growing the container (ACT-18).
Escape dismisses a snackbar that holds the focus (ACT-34).
Alt+G moves the focus to a snackbar that carries an action, from wherever the page had it
M3 asks for a documented shortcut on the web, since a snackbar never takes the focus itself
and a keyboard has no other way to reach one (ACT-34). It does nothing when the snackbar on
screen has no action.
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
48px for one line and 68px for two (SnackbarTokens.TwoLinesContainerHeight; the site's prose
says 64dp, and the token is the more precise of the two). A description is that second line,
so the container is pinned to 68px whenever one is there rather than left to grow into it. On
a compact window a two-line snackbar with an action wraps the action below the text, which is
the third of M3's five snackbar configurations ("two lines with longer action"). `position`:
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
`--material-bottom-bar`, and publishes its own height as `--material-snackbar-height` so a
FAB can lift clear of it — M3: a snackbar appears above a FAB, never in front of or behind
one (ACT-17). A compact window (below `medium`, 600px) gets the full-width snackbar; from
`medium` it hugs its line length instead, as M3 asks. --}}
`position`: `bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar
through `--material-bottom-bar`, and publishes its own height as `--material-snackbar-height`
so a FAB can lift clear of it (M3: a snackbar appears above a FAB, never in front of or
behind one). Drawn by resources/css/components/toast.css. --}}
@props(['position' => 'bottom'])