Merge branch 'worktree-agent-a4fc1072037346c95'
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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><x-slider></code>: M3 Expressive's slider on native range inputs — standard, range and centred, five sizes, ticks, value labels and an inset icon.
|
||||
<code><x-slider></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)
|
||||
|
||||
Reference in New Issue
Block a user