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:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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` (600–839) collapsed in the layout, opening as a modal; at `expanded` (840–1199)
|
||||
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. --}}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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** (600–839): 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** (840–1199): 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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 4–10s),
|
||||
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'])
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
With a build, the app's own Vite entries (`livewire-material.showcase.vite`) bring the
|
||||
foundation, tokens, scheme and font, and a `<style data-md-error-styles>` beside them inlines
|
||||
this layout's own resources/css/components/error-page.css with the button and shape
|
||||
stylesheets it imports (`ErrorPage::layoutStyles()`, `Stylesheets::bundle()`, cached). An app
|
||||
stylesheets it imports (`ErrorPage::layoutStyles()`, prebuilt in resources/dist/). An app
|
||||
whose entry imports them too (`all.css`) gets the same layered rules twice, which is harmless:
|
||||
the second copy repeats the first, in the same layers. But an error page is also what shows
|
||||
while a deploy has no build yet, so when those tags cannot be made the page brings a
|
||||
stylesheet built in PHP instead: the foundation and this layout's own rules, inlined the same
|
||||
way rather than copied by hand, plus the app's scheme from its scheme data in the same
|
||||
selector shape `material:scheme` writes (`src/Support/ErrorPage.php::fallbackStyles()`). The
|
||||
while a deploy has no build yet, so when those tags cannot be made the page brings a whole
|
||||
stylesheet instead: the foundation and this layout's own rules, prebuilt the same way rather
|
||||
than copied by hand, plus the app's scheme from its scheme data in the same selector shape
|
||||
`material:scheme` writes (`src/Support/ErrorPage.php::fallbackStyles()`). The
|
||||
two paths draw the same `data-md-error-*` hooks by construction, nothing to keep in step by
|
||||
hand. The shape turns once a minute, unless the visitor asks for reduced motion. --}}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{-- `<x-menu filter>`'s field and filtered list, drawn once here for both the popover and the
|
||||
compact sheet (resources/views/components/menu.blade.php). $key, $filterLabel, $label and
|
||||
$slot come from the including view's own scope.
|
||||
|
||||
$idSuffix builds the list's id and the field's `aria-controls` ("" for the popover,
|
||||
"-sheet-menu" for the sheet's own ids, which already carry that suffix). $wireKey keys the
|
||||
list div for a Livewire morph — null where an ancestor already carries the menu's own key.
|
||||
$delegated is true for the sheet, whose host already hears the field's `input` and `keydown`
|
||||
(x-on:input, x-on:keydown.capture on sheetHost in menu.blade.php); the popover has no such
|
||||
ancestor, so its own field carries `x-on:input` and `x-on:keydown.stop` directly. --}}
|
||||
<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 }}{{ $idSuffix }}-list"
|
||||
aria-label="{{ $filterLabel }}"
|
||||
placeholder="{{ $filterLabel }}"
|
||||
@unless ($delegated)
|
||||
x-on:input="refine()"
|
||||
x-on:keydown.stop="search($event)"
|
||||
@endunless
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
|
||||
'wire:key' => $wireKey,
|
||||
'id' => "material-menu-{$key}{$idSuffix}-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>
|
||||
@@ -0,0 +1,27 @@
|
||||
{{-- The AM/PM radio group, drawn once here and included from both the dial and the typing mode of
|
||||
resources/views/components/timepicker.blade.php, since the two draw it byte for byte alike. --}}
|
||||
<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>
|
||||
@@ -0,0 +1,20 @@
|
||||
{{-- The showcase's `<head>`, shared by layout.blade.php and shell.blade.php: the application's own
|
||||
entries without its stylesheets (ShowcaseAssetController::scripts() says why), then the
|
||||
showcase's own bundle — all.css, showcase.css and the application's scheme — as the page's
|
||||
only CSS. $pageTitle is the including view's own, already built. --}}
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>{{ $pageTitle }}</title>
|
||||
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
@if (($scripts = \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::scripts()) !== [])
|
||||
@vite($scripts)
|
||||
@endif
|
||||
@livewireStyles
|
||||
|
||||
<link rel="stylesheet" href="{{ \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::url() }}" />
|
||||
</head>
|
||||
@@ -8,6 +8,7 @@
|
||||
$section ??= null;
|
||||
// A page of a section's own (a canonical layout, ShowcaseController::layout()) names itself.
|
||||
$title ??= $section !== null ? $sections[$section]['title'] : 'Livewire Material';
|
||||
$pageTitle = $section !== null ? $title.' · Livewire Material' : 'Livewire Material';
|
||||
|
||||
$destinations = [[
|
||||
'title' => 'Overview',
|
||||
@@ -31,25 +32,7 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
|
||||
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
{{-- The application's own entries without its stylesheets (ShowcaseAssetController::scripts()
|
||||
says why): the CSS of every showcase page is the bundle linked below, and nothing else. --}}
|
||||
@if (($scripts = \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::scripts()) !== [])
|
||||
@vite($scripts)
|
||||
@endif
|
||||
@livewireStyles
|
||||
|
||||
{{-- all.css, showcase.css and the application's scheme, without the application's build. --}}
|
||||
<link rel="stylesheet" href="{{ \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::url() }}" />
|
||||
</head>
|
||||
@include('livewire-material::showcase.head')
|
||||
<body data-md-showcase>
|
||||
<x-livewire-material::scaffold :destinations="$destinations" label="Showcase" rail-width="17rem">
|
||||
<x-slot:brand>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<x-livewire-material::grid :columns="['compact' => 2, 'medium' => 5]" gap="space200">
|
||||
@foreach ($corners as $corner)
|
||||
<x-livewire-material::stack gap="space100">
|
||||
<span data-md-showcase-shape-swatch data-md-corner="{{ $corner }}"></span>
|
||||
<span data-md-showcase-shape-swatch data-md-surface data-md-corner="{{ $corner }}"></span>
|
||||
<code class="md-type-label-md md-ink-variant">{{ $corner }}</code>
|
||||
</x-livewire-material::stack>
|
||||
@endforeach
|
||||
|
||||
@@ -22,29 +22,12 @@
|
||||
->all();
|
||||
|
||||
$current = $pages[$page];
|
||||
$pageTitle = $current['title'].' · Scaffold · Livewire Material';
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>{{ $current['title'] }} · Scaffold · Livewire Material</title>
|
||||
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
{{-- As layout.blade.php: the application's entries without its stylesheets, then the
|
||||
showcase's own bundle as the page's only CSS. --}}
|
||||
@if (($scripts = \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::scripts()) !== [])
|
||||
@vite($scripts)
|
||||
@endif
|
||||
@livewireStyles
|
||||
|
||||
{{-- all.css, showcase.css and the application's scheme, without the application's build. --}}
|
||||
<link rel="stylesheet" href="{{ \NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController::url() }}" />
|
||||
</head>
|
||||
@include('livewire-material::showcase.head')
|
||||
<body data-md-showcase>
|
||||
<x-livewire-material::scaffold :destinations="$destinations">
|
||||
<x-slot:brand>
|
||||
|
||||
Reference in New Issue
Block a user