Merge branch 'worktree-agent-a6769a5cd2c4b070e'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:19:47 +02:00
26 changed files with 747 additions and 214 deletions
@@ -9,12 +9,20 @@
SheetBottomTokens (androidx Compose Material 3, Apache-2.0): surface-container-low, extra-large
top corners, elevation 1, a 32×4px drag handle in on-surface-variant; 640px wide at most, centred
on a wide screen; it rises on emphasized decelerate. `title` and `actions` as on a dialog.
`height` caps it (default: 90% of the screen); content scrolls inside. --}}
`height` is where it opens, and M3 caps a modal sheet's initial position at half the screen —
"if content exceeds that, it can be pulled to full screen and scrolled internally" — so the
default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls
inside.
The handle is drawn 32×4px and pressed 48×48: `touch-target` on the button and 22px above and
below it, which is M3's "drag handle has an accessible 48dp hit target" and SheetDefaults.kt's
`DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets Specs). --}}
@props([
'title' => null,
'standard' => false,
'height' => '90dvh',
'height' => '50dvh',
])
@php
@@ -50,14 +58,14 @@
role="dialog"
@unless ($standard) aria-modal="true" @endunless
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-max-height: {{ $height }}"
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
$attributes->get('class'),
]) }}
>
<div class="flex shrink-0 cursor-grab justify-center py-4 active:cursor-grabbing" data-drag-handle>
<button type="button" class="h-1 w-8 rounded-corner-full bg-on-surface-variant/40 outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
<div class="flex shrink-0 cursor-grab justify-center py-5.5 active:cursor-grabbing" data-drag-handle>
<button type="button" class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
</div>
<div x-ref="body" class="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
+29 -6
View File
@@ -7,11 +7,24 @@
`separator` (a divider under the header), and the `menu` (top-end, beside the title),
`figure` (full-bleed media on top) and `actions` (end-aligned, under the content) slots.
A card that opens something is a row: give it `data-list-row` and one `data-list-open`
control inside (the title link or a button), and a press anywhere on it reaches that control
while its other buttons keep their own (resources/js/list-rows.js). It answers with a state
layer and its corner opening a step. Never a stretched link, and never a whole-card `<a>`
around buttons.
M3 draws two kinds of card a person can press, and they differ in the keyboard: on a
**non-actionable card with actionable elements** Tab moves through each control inside before
the next card; on a **directly actionable card** Tab moves to the card, then to the next card
(docs/reference/m3/components-actions-communication-containment.md § Cards Accessibility).
Both are a row: give the card `data-list-row` and one `data-list-open` control inside (the
title link or a button), and a press anywhere on it reaches that control while its other
buttons keep their own (resources/js/list-rows.js). That is the first kind the opener is
the tab stop, the accessible name and the owner of the `href` or the `wire:click`.
`actionable` makes it the second: the card takes `tabindex="0"` and `role="button"` (pass
`role="link"` where it goes somewhere), is named by its `title`, and Enter or Space on it
reaches the opener. Put `tabindex="-1"` on that opener so the card is the one stop, and leave
the card's other actions as they are — they follow it in the tab order.
A row answers with M3's state layer and one step of elevation (elevated 1 2 hovered,
filled and outlined 0 1); its corner does not move, because M3 gives a card one shape.
Never a stretched link, and never a whole-card `<a>` around buttons.
Do not pass a `bg-*` class to change its fill it races the card's own in Tailwind's emit
order; use `variant`, or colour a wrapper inside. --}}
@@ -21,14 +34,24 @@
'subtitle' => null,
'variant' => 'filled',
'separator' => false,
'actionable' => false,
])
@php
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
$role = $actionable ? ($attributes->get('role') ?: 'button') : null;
$attributes = $actionable ? $attributes->except('role') : $attributes;
@endphp
<div
data-card
data-card="{{ $variant }}"
@if ($actionable)
data-list-row
data-list-actionable
tabindex="0"
role="{{ $role }}"
@if (filled($title)) aria-label="{{ $title }}" @endif
@endif
{{ $attributes->class([
'relative flex flex-col overflow-hidden rounded-corner-md text-on-surface',
'bg-surface-container-highest' => $variant === 'filled',
@@ -7,34 +7,56 @@
`label` lays a short line of text over the bottom of the art, on a scrim, pinned to the
item's visible edge and fading out as the item narrows — Compose's carousel sample, which
fades its label chip in once the mask is wide enough to hold it.
fades its label chip in once the mask is wide enough to hold it. The scrim is what M3 asks
for under text on an image; the text itself is `inverse-on-surface`, a role a scheme and a
contrast profile follow, rather than a literal white.
A `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it
(WAI-ARIA's carousel pattern). The item is laid out at the carousel's large size and masked:
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
arrow keys move between them and Space or Enter opens the focused one
(docs/reference/m3/components-actions-communication-containment.md § Carousel
Accessibility). The item is laid out at the carousel's large size and masked:
the surface inside is clipped by `--material-carousel-inset` from both sides with M3's
extra-large corner (28px, CarouselDefaults' item shape) and moved by
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
item shows unmasked, at its `item-width`. Only inside `<x-carousel>`. --}}
item shows unmasked, at its `item-width`. In a `full-screen` carousel it is none of that: the
item fills the row, edge to edge, with no corner and no mask. Only inside `<x-carousel>`. --}}
@props([
'label' => null,
])
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
edge-to-edge items, which M3 gives no corner and no mask. --}}
@aware([
'layout' => 'multi-browse',
])
@php
$vertical = $layout === 'full-screen';
@endphp
<div {{ $attributes
->class([
'relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always',
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
'w-full' => $vertical,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical,
])
->merge([
'role' => 'group',
'aria-roledescription' => __('slide'),
'aria-label' => '[material-carousel-position]',
'data-material-carousel-item' => true,
'tabindex' => '0',
]) }}>
<div
data-material-carousel-surface
class="relative size-full overflow-hidden rounded-corner-xl bg-surface-container-highest text-on-surface translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]"
@class([
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical,
])
>
<div data-material-carousel-content class="size-full translate-x-(--material-carousel-pin) [&>img]:size-full [&>img]:object-cover">
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
{{ $slot }}
</div>
@@ -43,7 +65,7 @@
data-material-carousel-label
class="pointer-events-none absolute inset-x-0 bottom-0 flex bg-linear-to-t from-scrim/60 to-transparent px-4 pt-10 pb-4 opacity-(--material-carousel-label)"
>
<span class="truncate type-title-md text-white translate-x-(--material-carousel-label-shift)">{{ $label }}</span>
<span class="truncate type-title-md text-inverse-on-surface translate-x-(--material-carousel-label-shift)">{{ $label }}</span>
</div>
@endif
</div>
+43 -22
View File
@@ -19,12 +19,19 @@
does.
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
- `full-screen`: one item the width of the carousel at a time
(FullScreenCarouselStrategy).
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** — M3: "this layout
works best with content that is taller than it is wide, and scrolls vertically. It only
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
landscape orientation." Items have no corner and no mask, 16px apart, no end padding, and
the row is never wider than the 840px medium window it is meant for. `item-width` and
`padding` do not apply; `height` is the height of each item, so give it the room a
portrait image wants.
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels (0): the first and last items
rest that far in from the edges while items in between scroll to them. Items are 8px apart
with M3's extra-large corner.
sample). `padding` is Compose's `contentPadding` in pixels: the first and last items rest
that far in from the edges while items in between scroll to them. M3's specs table gives
every layout 16dp of it — `uncontained` at the leading edge only, `full-screen` none — so
that is the default, with the 8dp above and below the row that goes with it. Items are 8px
apart with M3's extra-large corner.
The row is a native scroll container with CSS scroll snap, one item per swipe, as Compose's
single-advance fling; touch, trackpad and Shift with the wheel scroll it. resources/js/
@@ -34,14 +41,18 @@
frame, content at full size, so items change size between the keylines. Without script
the row still scrolls and snaps, unmasked.
WAI-ARIA's carousel pattern: the row is a focusable `region` with
`aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a
`group` with `aria-roledescription="slide"` named "n of m". With the row focused the arrow
keys move one item, Home and End to the ends; focus moving into an item, or a press on one
The row is a `region` with `aria-roledescription="carousel"`, named by `label` ("Carousel"
by default); each item is a focusable `group` with `aria-roledescription="slide"` named
"n of m". M3: "Use Tab to place initial focus on the first carousel item" and "avoid
focusing on the carousel container", so the items are the tab stops and the row is not one.
From a focused item the arrow keys move one item, Home and End go to the ends, and Space or
Enter opens an item that is not fully open; focus moving into an item, or a press on one
that is not fully open, brings it into focus. `controls` adds previous and next icon
buttons under the row — by default only where the pointer is fine (a mouse or trackpad);
`true` always, `false` never. Under reduced motion they scroll instantly and content no
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
`true` always, `false` never. Under reduced motion they scroll instantly and nothing is
masked: every item stays at its large size, which is M3's rule for a carousel under reduced
motion (docs/reference/m3/styles.md § Motion → Accessibility requirements). RTL mirrors the
keylines, keys and buttons.
Re-measures itself when resized, when a Livewire morph resets its styles and when items
come and go. The row's id, which the buttons control, is new with every render; the row
@@ -52,7 +63,7 @@
'layout' => 'multi-browse',
'itemWidth' => null,
'height' => null,
'padding' => 0,
'padding' => 16,
'centered' => false,
'label' => null,
'controls' => null,
@@ -60,6 +71,7 @@
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$vertical = $layout === 'full-screen';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
@@ -76,8 +88,9 @@
'hero' => $cssLength($itemWidth),
'full-screen' => null,
};
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
$slotWidth = match (true) {
$layout === 'full-screen' => '100%',
$vertical => null,
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
@@ -97,15 +110,22 @@
$slides,
);
// The specs table's leading and trailing padding: 16dp for multi-browse and hero, leading
// only for uncontained, none for full-screen, which is edge to edge.
$padding = max(0, (float) $padding);
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
$attributes = $attributes
->class('relative')
->merge(array_filter([
'data-material-carousel' => $layout,
'data-centered' => $layout === 'hero' && $centered ? true : null,
'data-padding' => (string) max(0, (float) $padding),
'data-padding' => (string) $paddingStart,
'data-padding-end' => (string) $paddingEnd,
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
"--material-carousel-slot: {$slotWidth}",
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@@ -123,11 +143,12 @@
role="region"
aria-roledescription="{{ __('carousel') }}"
aria-label="{{ $label }}"
tabindex="0"
@class([
'focus-ring flex h-(--material-carousel-height) gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
'flex',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'snap-x snap-mandatory' => $layout !== 'uncontained',
'mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain' => $vertical,
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained',
])
>
{!! $slides !!}
@@ -140,22 +161,22 @@
'flex' => $controls === true,
])>
<x-livewire-material::button
icon="chevron_left"
:icon="$vertical ? 'keyboard_arrow_up' : 'chevron_left'"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
class="rtl:-scale-x-100"
:class="$vertical ? '' : 'rtl:-scale-x-100'"
/>
<x-livewire-material::button
icon="chevron_right"
:icon="$vertical ? 'keyboard_arrow_down' : 'chevron_right'"
variant="tonal"
:tooltip="__('Next')"
aria-controls="{{ $scrollerId }}"
x-ref="next"
x-on:click="next()"
class="rtl:-scale-x-100"
:class="$vertical ? '' : 'rtl:-scale-x-100'"
/>
</div>
@endif
@@ -5,7 +5,8 @@
and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and where the browser supports animating `details` content (`interpolate-size`) a height
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
that eases open on the same spring (`data-collapse`, resources/css/components/collapse.css).
`open` starts it open. `variant`: `plain` (the default, on the surface around
it) or `filled` (a surface-container tile with a large corner).
Its open state can be bound, both ways:
@@ -38,6 +39,7 @@
<details
wire:ignore.self
data-collapse
@if ($bound)
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
@if ($model === null) x-modelable="collapseOpen" @endif
+18 -4
View File
@@ -25,6 +25,13 @@
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
never by the viewport.
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) — 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 a pane, which has
neither.
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
@@ -34,7 +41,7 @@
'separator' => false,
'side' => 'end',
'right' => true,
'withCloseButton' => false,
'withCloseButton' => true,
'closeOnEscape' => true,
'withoutBackdropClose' => false,
'width' => '25rem',
@@ -47,6 +54,10 @@
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
$start = $side === 'start';
// M3 requires a close affordance; the prop can only ever add one, never take away the last
// way out of the sheet.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape);
@endphp
<div
@@ -105,7 +116,7 @@
$attributes->get('class'),
]) }}
>
@if (filled($title) || $withCloseButton)
@if (filled($title) || $closeButton)
<div class="mb-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
@@ -118,7 +129,7 @@
@endif
</div>
@if ($withCloseButton)
@if ($closeButton)
<span class="-me-3 -mt-2 inline-flex shrink-0">
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
</span>
@@ -136,7 +147,10 @@
</div>
@isset($actions)
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6">
{{-- 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. --}}
<div class="flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4">
{{ $actions }}
</div>
@endisset
+41 -9
View File
@@ -8,12 +8,23 @@
`avatar` (an image URL, or initials in primary-container) or `image` (a 56px thumbnail), or a
`leading` slot (a checkbox, a switch). The trailing element is `trailing` text (label-small),
`icon-right`, or an `end` slot for controls (a menu, a switch). One-, two- and three-line
heights (56, 72, 88px) follow from what is given (ListTokens, androidx Compose Material 3, Apache-2.0).
heights (56, 72, 88px) follow from what is given, 16px between the item and what leads or
trails it, and a three-line item top-aligns rather than centring M3 aligns an item middle
"by default, top-aligned if the item is 88dp+ or has 3+ lines of text". Its icons are 24px,
or 20px in a `segmented` list, which is M3 Expressive's (ListTokens, androidx Compose
Material 3, Apache-2.0).
`link` makes the whole item the link. Otherwise, to make it open something while its trailing
controls keep their own presses, give it `data-list-row` and put `data-list-open` on the one
control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item,
in secondary-container; `disabled` greys it. --}}
in secondary-container; `disabled` greys it to 38%, drops its link and announces it
`aria-disabled` M3's states model treats disabled as not interactive, so a disabled item
answers neither the pointer nor the keyboard.
It takes its role from the `<x-list>` around it: a `listitem` in a plain list, where
`selected` is `aria-current`, and an `option` announcing `aria-selected` in a `selectable`
one. M3 asks for two cues on a selected item, never colour alone, so a selected option also
draws a trailing check unless `icon-right` says otherwise. --}}
@props([
'title' => null,
@@ -31,22 +42,41 @@
'disabled' => false,
])
{{-- The `<x-list>` around it: whether it is one a person chooses from and how many it takes, and
whether it is the expressive (segmented) one, whose icons are a size smaller. --}}
@aware([
'selectable' => false,
'selection' => null,
'segmented' => false,
])
@php
$isLink = filled($link);
$isLink = filled($link) && ! $disabled;
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
$option = $selectable || $selection !== null;
$check = $option && $selected && blank($iconRight);
// ListTokens.kt:153,156,332,335 — ItemLeading/TrailingIconExpressiveSize = 20dp against the
// baseline 24dp. At 20px the symbol is drawn from the 20 optical cut, not the 24 scaled down.
$iconSize = $segmented ? 'size-5' : 'size-6';
$optical = $segmented ? '20' : '24';
@endphp
<div
role="listitem"
role="{{ $option ? 'option' : 'listitem' }}"
@if ($option) aria-selected="{{ $selected ? 'true' : 'false' }}" @elseif ($selected) aria-current="true" @endif
data-list-item
@if ($isLink) data-list-row @endif
@if ($selected) data-selected @endif
@if ($disabled) aria-disabled="true" @endif
{{ $attributes->class([
'relative flex items-center gap-3 px-4 text-on-surface',
'relative flex gap-4 px-4 text-on-surface',
'items-center' => $lines !== 2,
'items-start' => $lines === 2,
'min-h-14 py-2' => $lines === 0,
'min-h-18 py-2.5' => $lines === 1,
'min-h-22 py-2.5' => $lines === 2,
'min-h-18 py-2' => $lines === 1,
'min-h-22 py-3' => $lines === 2,
'pointer-events-none text-on-surface/38' => $disabled,
]) }}
>
@@ -61,7 +91,7 @@
@elseif ($image)
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
@elseif ($icon)
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$icon" :optical="$optical" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@endif
<div class="min-w-0 flex-1">
@@ -94,6 +124,8 @@
@endif
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@elseif ($check)
<x-livewire-material::icon name="check" :optical="$optical" :class="$iconSize" />
@endif
</div>
+30 -8
View File
@@ -1,28 +1,50 @@
{{-- An M3 list: `<x-list-item>`s, one under another.
Plain by default the items sit on the surface around them, with `dividers` between them if
asked. `segmented` is M3 Expressive's list: each item its own surface-container tile, 2px
apart, with small corners that open to large at the ends and while hovered, pressed or
selected (ListTokens, androidx Compose Material 3, Apache-2.0).
asked, inset 16px from both ends as ListTokens' `DividerLeadingSpace`/`DividerTrailingSpace`
say (resources/css/components/list.css draws them). `segmented` is M3 Expressive's list: each
item its own surface tile, 2px apart, with small corners that open to large at the ends and
while hovered, pressed, focused or selected (ListTokens, androidx Compose Material 3,
Apache-2.0).
It is a `role="list"`; for keyboard walking between rows, `j`/`k` or arrows, the application
can use `data-list` (resources/js/list-rows.js marks rows; a list keyboard arrives with the
list-detail pane). `label` names the list. --}}
A plain list is a `role="list"` of `listitem`s. `selectable` (or `selection="single"` /
`selection="multi"`) makes it the list a person chooses from: M3 maps single- and
multi-select lists on the web to a **list box** of **options** with a selected state
(docs/reference/m3/components-actions-communication-containment.md § Lists Accessibility),
so the container becomes `role="listbox"` (`aria-multiselectable` when multi) and each item
an `option` that announces `aria-selected`. A selected option also draws a trailing check, so
selection is never colour alone; give the items a leading checkbox or radio instead and pass
`icon-right` to keep the check off.
For keyboard walking between rows, `j`/`k` or arrows, the application can use `data-list`
(resources/js/list-rows.js marks rows; a list keyboard arrives with the list-detail pane).
`label` names the list. --}}
@props([
'segmented' => false,
'dividers' => false,
'label' => null,
'selectable' => false,
'selection' => null,
])
@php
$selection = match (true) {
in_array($selection, ['single', 'multi'], true) => $selection,
$selectable || $selection !== null => 'single',
default => null,
};
@endphp
<div
role="list"
role="{{ $selection === null ? 'list' : 'listbox' }}"
@if ($selection === 'multi') aria-multiselectable="true" @endif
data-list="{{ $segmented ? 'segmented' : 'plain' }}"
@if ($dividers && ! $segmented) data-dividers @endif
@if ($label) aria-label="{{ $label }}" @endif
{{ $attributes->class([
'flex flex-col',
'gap-0.5' => $segmented,
'divide-y divide-outline-variant' => $dividers && ! $segmented,
]) }}
>
{{ $slot }}
+59 -28
View File
@@ -20,9 +20,21 @@
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
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"), with a close button and the title in a top 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. --}}
full-screen dialogs "only in compact breakpoints"), with a 56px 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): 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.
`separator` draws M3's divider under the pinned header and above the pinned actions.
`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
dialog's description. --}}
@props([
'title' => null,
@@ -31,12 +43,18 @@
'separator' => false,
'persistent' => false,
'fullscreen' => false,
'alert' => false,
'boxClass' => null,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10);
$header = filled($title) || filled($subtitle) || $icon;
$describedBy = implode(' ', array_filter([
filled($subtitle) ? $id.'-subtitle' : null,
$alert && $slot->isNotEmpty() ? $id.'-body' : null,
]));
@endphp
<dialog
@@ -53,7 +71,9 @@
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
x-on:close="if (open) close()"
@if (! $persistent) x-on:click.self="close()" @endif
@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', 'class'])->merge(['id' => $id]) }}
@class([
'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface',
@@ -64,12 +84,12 @@
])
>
<div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-medium:h-full max-medium:rounded-corner-none max-medium:p-0 max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
'flex max-h-[inherit] flex-col overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3',
'max-medium:h-full max-medium:rounded-corner-none max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass,
])>
@if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 medium:hidden">
<div class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden">
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
@@ -78,35 +98,46 @@
</div>
@endif
<div @class(['min-h-0 flex-1', 'max-medium:overflow-y-auto max-medium:px-6 max-medium:pb-6' => $fullscreen])>
@if (filled($title) || filled($subtitle) || $icon)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['shrink-0 px-6 pt-6', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if (filled($subtitle))
<p id="{{ $id }}-subtitle" @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
<div class="type-body-md text-on-surface-variant">{{ $slot }}</div>
</div>
@if ($slot->isNotEmpty())
<div id="{{ $id }}-body" @class([
'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant',
'pt-4' => $header,
'pt-6' => ! $header,
'pb-6' => ! isset($actions),
])>
{{ $slot }}
</div>
@endif
@isset($actions)
@if ($separator)
<x-livewire-material::divider class="shrink-0" />
@endif
<div @class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6',
'max-medium:border-t max-medium:border-outline-variant max-medium:px-6 max-medium:py-4' => $fullscreen,
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6',
'max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2' => $fullscreen,
])>
{{ $actions }}
</div>
+4 -4
View File
@@ -203,7 +203,7 @@ img {
width: 100%;
}
/* Header: the app name in title-lg, or the configured logo */
/* Header: the app name in title-lg (22/28 at weight 400), or the configured logo */
.header {
padding: 32px 0 24px;
@@ -213,7 +213,7 @@ img {
.header a {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 500;
font-weight: 400;
letter-spacing: 0;
line-height: 28px;
text-decoration: none;
@@ -226,7 +226,7 @@ img {
margin: 0 auto;
}
/* Body: the card, separated from the page by tone alone */
/* Body: the card, separated from the page by tone alone, on M3's extra-large corner */
.body {
-premailer-cellpadding: 0;
@@ -245,7 +245,7 @@ img {
-premailer-width: 570px;
background-color: {{ $card }};
border: 0;
border-radius: 24px;
border-radius: 28px;
margin: 0 auto;
padding: 0;
width: 570px;
@@ -55,8 +55,8 @@
@endforeach
</x-carousel>
BLADE,
'Full-screen' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="240" :controls="true">
'Full-screen: one item at a time, scrolled down' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
@foreach ([
['Morning', 'very-sunny', 'bg-linear-to-br from-primary-container to-tertiary-container text-on-primary-container'],
['Noon', 'clam-shell', 'bg-linear-to-br from-secondary-container to-primary-container text-on-secondary-container'],
@@ -78,7 +78,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-carousel&gt;</code> and <code>&lt;x-carousel-item&gt;</code>: items that change size between M3's keylines as they scroll.
Swipe, scroll with Shift and the wheel, or focus a row and use the arrow keys.
Swipe, scroll with Shift and the wheel, or tab to an item and use the arrow keys.
</p>
@foreach ($examples as $title => $code)
@@ -26,6 +26,15 @@
</x-slot:actions>
</x-card>
BLADE,
'A directly actionable card: the card is the tab stop' => <<<'BLADE'
<x-card variant="elevated" actionable role="link" title="contract.pdf" class="w-full max-w-sm">
<a href="#containment" data-list-open tabindex="-1" class="type-title-md">contract.pdf</a>
<p class="mt-1 text-on-surface-variant">1.2 MB · downloaded twice</p>
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" />
</x-slot:actions>
</x-card>
BLADE,
'Lists' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<x-list dividers label="Files">
@@ -40,9 +49,17 @@
<x-list-item title="Chiara Rossi" avatar="CR">
<x-slot:end><x-button icon="more_vert" tooltip="More" /></x-slot:end>
</x-list-item>
<x-list-item title="Dan Fischer" description="Invitation withdrawn" avatar="DF" link="#containment" disabled />
</x-list>
</div>
BLADE,
'A list to choose from' => <<<'BLADE'
<x-list selectable label="Expiry" class="w-full max-w-sm">
<x-list-item title="One hour" :selected="false" />
<x-list-item title="Three days" :selected="true" />
<x-list-item title="Thirty days" :selected="false" />
</x-list>
BLADE,
'Dividers and collapse' => <<<'BLADE'
<div class="w-full space-y-4">
<x-collapse title="How long do links last?" icon="schedule" open>
@@ -70,7 +87,7 @@
'Dialogs' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
<x-modal title="Delete this share?" subtitle="Recipients lose access at once. This cannot be undone.">
<x-modal title="Delete this share?" subtitle="Recipients lose access at once. This cannot be undone." alert>
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Delete" danger x-on:click="close()" />