Merge branch 'worktree-agent-a6769a5cd2c4b070e'
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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><x-carousel></code> and <code><x-carousel-item></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()" />
|
||||
|
||||
Reference in New Issue
Block a user