Merge branch 'worktree-agent-a4fc1072037346c95'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:44:46 +02:00
26 changed files with 950 additions and 114 deletions
@@ -10,7 +10,13 @@
(resources/css/components/selection.css).
Without a label a "select all" in a table header, a row's tick — the row is only the 18px box,
so the box carries `touch-target` and catches presses over M3's 48px minimum. --}}
so the box carries `touch-target` and catches presses over M3's 48px minimum.
Grouping is the caller's, not this component's: M3 asks that from `expanded` (840px) a set of
related checkboxes be gathered into a contained region rather than left as one long column
(docs/reference/m3/components-navigation-selection-inputs.md § Checkbox, Behaviour). Lay the
group out yourself `<div class="grid gap-4 expanded:grid-cols-2">` around the checkboxes, or a
`<x-card>` or side sheet holding them and give the group a heading that names what it asks. --}}
@props([
'label' => null,
+35 -11
View File
@@ -10,8 +10,12 @@
its items: `kinds` and `kinds.*`) replaces the hint.
`scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a narrow
screen: the edge it can still scroll towards fades (resources/js/chips.js), and a chip reached
with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set.
screen. M3's chips accessibility page asks that a row which overflows say so, so the edge it
can still scroll towards fades (resources/js/chips.js) and, where the pointer is fine and
there is no swipe to reach for, a button sits over each fading edge and scrolls the row by
most of its width. The buttons are pointer affordances only — `tabindex="-1"` and
`aria-hidden`, because the arrow keys already walk every chip and scroll each one clear of
the fade. Removing a focused input chip moves focus within the set.
The set is one tab stop and the arrow keys move between the chips inside it, with Home and End
at the ends — M3's chip keyboard table. Backspace and Delete still remove a focused input
@@ -43,15 +47,35 @@
@endif
@if ($scroll)
<div
data-chip-set
x-data="materialChipSet"
x-on:keydown="key($event)"
x-on:focusin="rove($event.target)"
wire:ignore.self
class="-mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
>
{{ $slot }}
<div x-data="materialChipSet" class="relative">
<div
data-chip-set
x-ref="row"
x-on:keydown="key($event)"
x-on:focusin="rove($event.target)"
wire:ignore.self
class="peer -mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 pointer-fine:scroll-px-10 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
>
{{ $slot }}
</div>
{{-- After the row, so each button can ask whether the row can still scroll its way. --}}
@foreach (['start' => 'chevron_left', 'end' => 'chevron_right'] as $edge => $glyph)
<button
type="button"
tabindex="-1"
aria-hidden="true"
data-chip-scroll="{{ $edge }}"
x-on:click="nudge('{{ $edge }}')"
@class([
'absolute inset-y-0 my-auto hidden size-8 place-items-center rounded-corner-full bg-surface-container-high text-on-surface-variant shadow-elevation-1',
'start-0 peer-data-scroll-start:pointer-fine:grid' => $edge === 'start',
'end-0 peer-data-scroll-end:pointer-fine:grid' => $edge === 'end',
])
>
<x-livewire-material::icon :name="$glyph" class="size-4.5 rtl:-scale-x-100" optical="20" />
</button>
@endforeach
</div>
@else
<div data-chip-set x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)" class="flex flex-wrap gap-2">
@@ -10,6 +10,11 @@
calendar in a dialog, with a pencil to switch to typing.
- `input`: the same dialog, opened on its text field, with a calendar icon to switch back.
A `range` picker on a compact window (below `medium`) is M3's full-screen range picker instead
of the 360px dialog: an app bar with a close button and **Save**, the range as the headline
under the supporting text, and the months in one vertically scrolling list rather than stepped
one at a time. Nothing changes for a single date, or for a docked picker at `medium` and up.
The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the
value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed
into the docked field is the value as soon as it is a whole, allowed date.
@@ -265,6 +270,30 @@
data-datepicker-picker
>
<div tabindex="-1" data-datepicker-surface>
{{-- M3's full-screen range picker (compact): an app bar with a close button and Save,
then the supporting text and the range as the headline, over a divider. --}}
<div data-datepicker-full x-cloak x-show="presentation === 'full'">
<div data-datepicker-app-bar>
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="cancel()" data-datepicker-close />
<span data-datepicker-app-bar-actions>
<span x-show="! typing">
<x-livewire-material::button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
<span x-show="typing">
<x-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
<x-livewire-material::button :label="__('Save')" x-on:click="confirm()" data-datepicker-save />
</span>
</div>
<div data-datepicker-full-headline>
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
</div>
</div>
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
@@ -280,7 +309,7 @@
</div>
</div>
<div x-show="! typing" data-datepicker-calendar>
<div x-show="! typing" x-on:scroll="extendMonths($event)" data-datepicker-calendar>
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
<div data-datepicker-nav x-show="presentation === 'modal'">
@@ -350,9 +379,14 @@
</tr>
</thead>
<tbody>
<template x-for="(week, row) in weeks" :key="row">
<template x-for="row in rows" :key="row.key">
<tr>
<template x-for="(cell, column) in week" :key="column">
{{-- Full screen: a month's name heads its weeks, M3's month label. --}}
<template x-if="row.label">
<th colspan="7" scope="colgroup" x-text="row.label" data-datepicker-month-label></th>
</template>
<template x-for="(cell, column) in (row.cells ?? [])" :key="column">
<td
role="gridcell"
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
@@ -469,7 +503,8 @@
</div>
</div>
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
{{-- The full-screen picker confirms from its app bar, so it draws no action row. --}}
<div x-show="presentation !== 'full' && (view === 'days' || typing || presentation === 'modal')" data-datepicker-actions>
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
</div>
+57 -7
View File
@@ -14,6 +14,17 @@
at the end of the row so the state has two indicators and not only a colour unless the caller
trails the field with something of its own, or the field is `xs` and has no room.
`counter` is the maximum number of characters, and puts M3's character counter at the end of the
supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once
the value is past the maximum
(docs/reference/m3/components-navigation-selection-inputs.md § Text Fields). It is said as
"Character count, 5/20", M3's own label, from a polite region a second after typing stops rather
than on every keystroke, which would talk over the typing.
From `medium` the field is no wider than 40rem, because M3 asks that a text field never span
the full width of a large screen (§ Text Fields, Behaviour); a `max-w-*` class from the call
site beats that, and `full` takes it off altogether.
`class` from the call site lands on the outermost element, never the control, so a margin or
a width is safe here. --}}
@@ -30,6 +41,8 @@
'variant' => null,
'floated' => false,
'mono' => false,
'counter' => null,
'full' => false,
])
@php
@@ -40,6 +53,7 @@
// M3 pairs the error colours with a trailing error icon so the state has two indicators. It
// gives way to whatever the caller trails the field with, and to `xs`, which has no room.
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
$counter = filled($counter) && (int) $counter > 0 ? (int) $counter : null;
@endphp
<div
@@ -49,6 +63,7 @@
@if ($messages !== []) data-invalid @endif
@if ($floated) data-floated @endif
@if ($mono) data-mono @endif
@if ($full) data-full @endif
>
<div class="field-box">
@if ($icon)
@@ -80,13 +95,48 @@
@endif
</div>
@if ($messages !== [])
<div id="{{ $id }}-support" class="field-support" role="alert">
@foreach ($messages as $message)
<p>{{ $message }}</p>
@endforeach
@if ($messages !== [] || filled($hint) || $counter !== null)
<div class="field-support-row">
@if ($messages !== [])
<div id="{{ $id }}-support" class="field-support" role="alert">
@foreach ($messages as $message)
<p>{{ $message }}</p>
@endforeach
</div>
@elseif (filled($hint))
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
@endif
@if ($counter !== null)
<span
class="field-counter"
data-field-counter
x-data="{
used: 0,
max: {{ $counter }},
spoken: '',
settle: null,
init() {
const control = this.$el.closest('.field').querySelector('.field-control');
this.used = control.value.length;
control.addEventListener('input', () => (this.used = control.value.length));
this.$watch('used', () => {
clearTimeout(this.settle);
this.settle = setTimeout(() => (this.spoken = @js(__('Character count, :used/:max')).replace(':used', this.used).replace(':max', this.max)), 1000);
});
},
destroy() {
clearTimeout(this.settle);
},
}"
x-bind:data-over="used > max"
>
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
<span class="sr-only" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
</span>
@endif
</div>
@elseif (filled($hint))
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
@endif
</div>
+8 -2
View File
@@ -4,7 +4,9 @@
value, such as a currency or a unit), `variant`, `size` for unlabelled controls in toolbars,
and `mono` for a field that holds code. `clearable` adds a trailing button that empties the
field once it holds something; `copyable` one that copies its value and says so in a snackbar
(a share link, a token). Every other attribute reaches the `<input>`: `type`, `min`, `step`,
(a share link, a token). `counter` counts the characters used against `maxlength`, at the end of
the supporting-text row; `full` lets the field span its pane past the 40rem M3 bounds it to
from `medium`. Every other attribute reaches the `<input>`: `type`, `min`, `step`,
`readonly`, `wire:model` and the rest. Errors are read from the bag under the `wire:model` name.
The placeholder is a single space when none is given, because the label can only tell an
@@ -21,6 +23,8 @@
'suffix' => null,
'clearable' => false,
'copyable' => false,
'counter' => false,
'full' => false,
'size' => 'md',
'variant' => null,
'mono' => false,
@@ -33,9 +37,11 @@
// 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)) : [];
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$max = $counter ? $attributes->get('maxlength') : null;
@endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}"
+40 -5
View File
@@ -13,6 +13,15 @@
a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
`<input type="search">`.
`trigger="icon"` is M3's second entry point, the search icon button: search as a secondary
action in a toolbar or an app bar, a single 48px button that expands into the full-screen view
and hands focus to the field (so `docked` has nothing to say about it). The bar itself is the
view's header there, and closing gives the button its focus back.
`suggestions` is a slot shown in the view until the first keystroke M3's suggestions
behaviour: recent searches, popular queries. The results slot takes over once something is
typed, and whichever of the two is on screen is what the live region counts.
The bar is a combobox: the wrapper around the input carries `role="combobox"` with
`aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are
a list, and a polite live region says how many of them there are whenever they change — M3 asks
@@ -23,22 +32,26 @@
'label' => null,
'icon' => 'search',
'docked' => false,
'trigger' => 'bar',
])
@php
$model = $attributes->wire('model')->value() ?: null;
$placeholder ??= __('Search');
$id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
$trigger = $trigger === 'icon' ? 'icon' : 'bar';
$announce = [
'none' => __('No results'),
'one' => __('1 result'),
'many' => __(':count results'),
'suggestionOne' => __('1 suggestion'),
'suggestionMany' => __(':count suggestions'),
];
@endphp
<div
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce))"
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce), @js($trigger))"
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
x-on:focusout="leave($event)"
x-on:pointerdown.outside="close()"
@@ -46,16 +59,34 @@
x-bind:data-open="open ? '' : null"
x-bind:data-full-screen="fullScreen ? '' : null"
data-search
data-trigger="{{ $trigger }}"
{{ $attributes->only(['class', 'wire:key'])->class(['relative']) }}
>
<div data-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div>
@if ($trigger === 'icon')
<button
type="button"
x-ref="trigger"
x-show="! open"
x-on:click="expand()"
aria-label="{{ $label ?? $placeholder }}"
aria-haspopup="dialog"
aria-controls="{{ $id }}-view"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
data-search-trigger
>
<x-livewire-material::icon :name="$icon" />
</button>
@endif
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen">
<x-livewire-material::icon :name="$icon" />
</span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}">
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}">
<x-livewire-material::icon name="arrow_back" />
</button>
@@ -78,7 +109,7 @@
aria-label="{{ $label ?? $placeholder }}"
x-on:focus="focused()"
x-on:click="show()"
x-on:input="show()"
x-on:input="typed($event)"
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
data-search-input
/>
@@ -103,10 +134,14 @@
x-on:keydown.arrow-up.prevent="step(-1)"
x-on:click="choose($event)"
>
@isset($suggestions)
<div data-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
@endisset
@if ($slot->hasActualContent())
<div data-search-results role="list">{{ $slot }}</div>
<div data-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
@elseif (isset($empty))
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
<div data-search-results @isset($suggestions) x-cloak x-show="query !== ''" @endisset><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
@endif
</div>
+56 -12
View File
@@ -26,6 +26,15 @@
`secondary`, `tertiary`, `error`, `success`, `warning`, `info` — the active track and the
handle; the inactive track is its container (secondary-container for primary).
`orientation="vertical"` stands the slider up, M3 Expressive's second orientation: the same
drawing, turned a quarter so the value grows upwards, with the value label beside the handle
instead of above it and every size unchanged. The keyboard is the native range's, so Up and
Right raise the value and Down and Left lower it. A vertical slider is as wide as a horizontal
one is tall and takes its length from the wrapper, so give it an explicit height —
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range
sliders horizontal ("never use range sliders vertically — too much cognitive load"), so
`range` wins over `orientation`.
`label` is shown above and names the input (with `range`, the group; its handles are "Range
start" and "Range end"); `hint` goes below, and a validation message for the bound property
(or `name`) replaces it. Other attributes go to the input(s).
@@ -71,6 +80,7 @@
'valueLabel' => 'drag',
'icon' => null,
'color' => 'primary',
'orientation' => 'horizontal',
'disabled' => false,
])
@@ -81,6 +91,8 @@
$color = in_array($color, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? $color : 'primary';
$valueLabel = in_array($valueLabel, ['always', 'drag', 'never'], true) ? $valueLabel : 'drag';
$centered = $centered && ! $range;
// M3 § Sliders, Behaviour: "range sliders should stay horizontal only".
$vertical = $orientation === 'vertical' && ! $range;
$minimum = is_numeric($min) ? (float) $min : 0.0;
$maximum = is_numeric($max) && (float) $max > $minimum ? (float) $max : $minimum + 100;
@@ -267,10 +279,19 @@
][$size];
// The label's bottom sits 4px above the handle: half of 44, 44, 52, 68 and 108px, plus 4.
$labelBottom = ['xs' => 'bottom-[calc(50%+1.625rem)]', 'sm' => 'bottom-[calc(50%+1.625rem)]', 'md' => 'bottom-[calc(50%+1.875rem)]', 'lg' => 'bottom-[calc(50%+2.375rem)]', 'xl' => 'bottom-[calc(50%+3.625rem)]'][$size];
// The measure across the slider — the handle's height, and so the slider's own. Standing up it
// is the width instead, and the length comes from the wrapper the caller sized.
$across = [
'xs' => $vertical ? 'w-12' : 'h-12',
'sm' => $vertical ? 'w-12' : 'h-12',
'md' => $vertical ? 'w-13' : 'h-13',
'lg' => $vertical ? 'w-17' : 'h-17',
'xl' => $vertical ? 'w-27' : 'h-27',
][$size];
$thumbs = $range ? ['start', 'end'] : ['start'];
@endphp
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized]) }}>
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized && ! $vertical, 'flex w-fit flex-col' => $vertical]) }}>
@if (filled($label))
@if ($range)
<span id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</span>
@@ -284,20 +305,31 @@
x-on:pointerdown="press($event)"
data-slider
data-size="{{ $size }}"
@if ($vertical) data-orientation="vertical" @endif
@if ($centered) data-centered @endif
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
@class([
'group/slider relative cursor-pointer touch-pan-y select-none has-disabled:cursor-not-allowed',
'h-12' => in_array($size, ['xs', 'sm'], true),
'h-13' => $size === 'md',
'h-17' => $size === 'lg',
'h-27' => $size === 'xl',
// Room above for a label that never hides: half the handle, 4px, and the 44px pill.
'mt-12' => $valueLabel === 'always',
'group/slider relative cursor-pointer select-none has-disabled:cursor-not-allowed',
$across,
'touch-pan-y' => ! $vertical,
// Standing up: 192px long unless the wrapper says otherwise, and a size container,
// which is what lets the drawing below be as long as this is tall.
'touch-pan-x h-48 min-h-0 flex-auto [container-type:size]' => $vertical,
// Room for a label that never hides: half the handle, 4px and the 44px pill — above the
// track when the slider lies down, beside it when it stands up.
'mt-12' => $valueLabel === 'always' && ! $vertical,
'ms-12' => $valueLabel === 'always' && $vertical,
'noscript:h-auto noscript:cursor-auto noscript:space-y-2',
])
>
<div data-slider-drawing wire:ignore aria-hidden="true" class="pointer-events-none absolute inset-y-0 inset-x-0.5 rtl:-scale-x-100 noscript:hidden">
<div data-slider-drawing wire:ignore aria-hidden="true" @class([
'pointer-events-none absolute noscript:hidden',
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical,
// The same drawing, laid out as long as the slider is tall and then turned a quarter
// anticlockwise so the value grows upwards. Everything inside it — the segments, the
// ticks, the handle and its label — is placed as if the slider were lying down.
'top-1/2 left-1/2 h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90' => $vertical,
])>
<div @class(['absolute inset-x-0 top-1/2 -translate-y-1/2', $trackHeight])>
@foreach (['start' => $inactiveInk[$color], 'active' => $activeInk[$color], 'end' => $inactiveInk[$color]] as $part => $ink)
<span
@@ -337,7 +369,14 @@
data-track-icon
@if (! $iconInActive && ! $iconInInactive) hidden @endif
@if ($iconInActive) data-active @endif
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
@class([
'absolute top-1/2 flex -translate-y-1/2',
'rtl:-scale-x-100' => ! $vertical,
// Turned back the quarter the drawing turns, so the glyph stands up.
'rotate-90' => $vertical,
$iconInk[$color],
'group-has-disabled/slider:text-on-surface/38',
])
style="left: {{ $calc($iconAt) }}"
>
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
@@ -358,11 +397,15 @@
])></span>
@if ($valueLabel !== 'never')
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center rtl:-scale-x-100', $labelBottom])>
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center', 'rtl:-scale-x-100' => ! $vertical, $labelBottom])>
<span
data-value-label
@class([
'flex h-11 min-w-12 origin-bottom items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
'flex h-11 min-w-12 items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
'origin-bottom' => ! $vertical,
// Turned back the quarter the drawing turns: the pill lands
// beside the handle rather than above it, and reads across.
'rotate-90' => $vertical,
'transition-[opacity,scale] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
'opacity-0 scale-75 group-data-pressed/thumb:opacity-100 group-data-pressed/thumb:scale-100 group-data-focused/thumb:opacity-100 group-data-focused/thumb:scale-100' => $valueLabel === 'drag',
])
@@ -383,6 +426,7 @@
step="{{ $continuous ? 'any' : $format($interval) }}"
value="{{ $format($values[$index]) }}"
data-thumb="{{ $thumb }}"
@if ($vertical) aria-orientation="vertical" @endif
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
@if ($described) aria-describedby="{{ $described }}" @endif
@if ($messages !== []) aria-invalid="true" @endif
@@ -3,7 +3,9 @@
It starts at `rows` lines (3) and grows with what is typed up to `max-rows`, then scrolls;
`:autogrow="false"` keeps it at `rows` and lets it be resized by hand, vertically only, so a
form's column never moves. The label rests on the first line rather than in the middle of the
box. `label`, `hint`, `variant`; every other attribute reaches the `<textarea>`. --}}
box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and
`full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other
attribute reaches the `<textarea>`. --}}
@props([
'label' => null,
@@ -13,6 +15,8 @@
'rows' => 3,
'maxRows' => null,
'autogrow' => true,
'counter' => false,
'full' => false,
])
@php
@@ -22,9 +26,11 @@
// 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)) : [];
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$max = $counter ? $attributes->get('maxlength') : null;
@endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}"
@@ -52,8 +52,9 @@
</x-chip-set>
BLADE,
'A set that scrolls' => <<<'BLADE'
{{-- M3 asks an overflowing chip row for a visible affordance: the edge it can still scroll towards fades, and on a mouse a button sits over it. --}}
<div class="w-full max-w-sm">
<x-chip-set label="Sort and filter" scroll>
<x-chip-set label="Sort and filter" hint="Drag, or use the arrow keys" scroll>
<x-chip type="filter" label="Newest" name="sort[]" value="newest" :selected="true" />
<x-chip type="filter" label="Largest" name="sort[]" value="largest" />
<x-chip type="filter" label="Expiring soon" name="sort[]" value="expiring" />
@@ -41,6 +41,24 @@
</div>
</div>
BLADE,
'Width at medium and above' => <<<'BLADE'
<div class="w-full space-y-4">
{{-- M3: never let a text field span the full width of a large screen. From medium it stops at 40rem. --}}
<x-input label="Bounded, the default" hint="No wider than 40rem from medium, however wide the pane is" />
<x-input label="Narrower" class="max-w-xs" hint="A max-w-* class from the call site wins" />
<x-input label="The width of the pane" full icon="search" hint="`full` takes the bound off — for a search row or an editor" />
</div>
BLADE,
'Character counter' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4">
<x-input label="Share name" maxlength="40" counter hint="Recipients see this name" />
<x-input variant="filled" label="Subject" maxlength="60" counter />
</div>
<x-textarea label="Message for recipients" maxlength="180" counter rows="3" hint="Sent with the link" />
</div>
BLADE,
'Textarea and select' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4">
@@ -74,6 +92,23 @@
</div>
</div>
BLADE,
'A checkbox group at expanded' => <<<'BLADE'
{{-- M3: from expanded (840px), gather related checkboxes into a contained region instead of one long column. --}}
<x-card variant="outlined" class="w-full max-w-2xl">
<fieldset>
<legend class="mb-4 type-title-sm text-on-surface">Tell me about</legend>
<div class="grid gap-4 expanded:grid-cols-2">
<x-checkbox label="Downloads" name="notify[]" value="downloads" checked />
<x-checkbox label="Comments" name="notify[]" value="comments" />
<x-checkbox label="Expiring shares" name="notify[]" value="expiring" checked />
<x-checkbox label="Failed uploads" name="notify[]" value="failed" />
<x-checkbox label="New team members" name="notify[]" value="members" />
<x-checkbox label="Weekly summary" name="notify[]" value="summary" />
</div>
</fieldset>
</x-card>
BLADE,
'Radio buttons' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<x-radio label="Who can open the link" name="showcase-audience" value="password" :options="[['id' => 'anyone', 'name' => 'Anyone with the link'], ['id' => 'password', 'name' => 'Anyone with the password', 'hint' => 'Share the password separately'], ['id' => 'team', 'name' => 'My team only', 'disabled' => true]]" />
@@ -134,6 +169,40 @@
</div>
</div>
BLADE,
'Search entry points and suggestions' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<div x-data="{ query: '' }">
<x-search x-model="query" placeholder="Search shares">
<x-slot:suggestions>
<x-list>
<x-list-item title="Recent: holiday photos" icon="history" link="#fields" />
<x-list-item title="Recent: contract" icon="history" link="#fields" />
<x-list-item title="Shares expiring this week" icon="trending_up" link="#fields" />
</x-list>
</x-slot:suggestions>
<x-list>
<x-list-item title="holiday-photos.zip" description="248 MB" icon="folder_zip" link="#fields" x-show="'holiday-photos.zip'.includes(query.toLowerCase())" />
<x-list-item title="contract.pdf" description="1.2 MB" icon="picture_as_pdf" link="#fields" x-show="'contract.pdf'.includes(query.toLowerCase())" />
</x-list>
</x-search>
</div>
{{-- M3's search icon button: search as a secondary action, expanding into the full-screen view. --}}
<div class="flex items-center gap-2 rounded-corner-full bg-surface-container-high px-2 py-1">
<x-button icon="menu" aria-label="Open the menu" />
<span class="grow type-title-md text-on-surface">Shares</span>
<x-search trigger="icon" label="Search shares">
<x-slot:suggestions>
<x-list>
<x-list-item title="Recent: design review" icon="history" link="#fields" />
</x-list>
</x-slot:suggestions>
<x-slot:empty>No shares match.</x-slot:empty>
</x-search>
</div>
</div>
BLADE,
'A form' => <<<'BLADE'
<x-card variant="outlined" class="w-full max-w-xl">
<x-form x-on:submit.prevent="materialToast('Share created', { type: 'success' })" separator>
@@ -27,6 +27,7 @@
</div>
BLADE,
'Range' => <<<'BLADE'
{{-- Narrow the window below 600px: a range picker becomes M3's full-screen one, with a close button, Save, and the months scrolled through. --}}
<div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
<div class="grid content-start gap-4">
<x-datepicker label="Trip" range x-model="trip" />
@@ -34,7 +35,7 @@
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
<x-datepicker label="Leave" range mode="modal" x-model="leave" hint="Full screen on a compact window" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
</div>
</div>
@@ -22,6 +22,16 @@
<x-slider label="Balance" value="15" :min="-50" :max="50" centered />
</div>
BLADE,
'Vertical' => <<<'BLADE'
{{-- M3 Expressive's second orientation. A vertical slider needs a height: it is on the wrapper, which the label and hint share. --}}
<div class="flex flex-wrap items-end gap-10">
<x-slider label="Volume" orientation="vertical" value="40" class="h-64" hint="Up and Down" />
<x-slider label="Warmth" orientation="vertical" value="6" :max="10" ticks class="h-64" />
<x-slider label="Brightness" orientation="vertical" value="70" size="md" icon="light_mode" class="h-64" />
<x-slider label="Always labelled" orientation="vertical" value="55" value-label="always" color="tertiary" class="h-64" />
<x-slider label="Locked" orientation="vertical" value="25" disabled class="h-64" />
</div>
BLADE,
'Colours and value labels' => <<<'BLADE'
<div class="grid w-full gap-8 expanded:grid-cols-3">
<x-slider label="Always labelled" value="55" color="secondary" value-label="always" size="sm" />
@@ -48,7 +58,7 @@
<h2 class="type-headline-md">Sliders</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-slider&gt;</code>: M3 Expressive's slider on native range inputs standard, range and centred, five sizes, ticks, value labels and an inset icon.
<code>&lt;x-slider&gt;</code>: M3 Expressive's slider on native range inputs standard, range and centred, horizontal or vertical, five sizes, ticks, value labels and an inset icon.
</p>
@foreach ($examples as $title => $code)