Overflow an app bar's trailing actions below medium
Plan step 25, item 7 (navigation Missing: "App bar, trailing-action overflow at small widths"). `<x-app-bar :actions="[...]">` takes the trailing actions as a list whose entries take <x-menu-item>'s props plus any action attribute. M3 allows up to two trailing icon buttons, and up to four on larger screens, and lets trailing actions collapse into an overflow menu at smaller breakpoints: below medium the bar keeps at most two icon buttons, from medium four, the "More options" button counted among them, the rest in its menu. Both forms render and media queries pick one, so there is no script and no flash; each width has its own menu so the arrow keys never reach a hidden row. The actions slot still takes markup and never overflows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
12cdeaaf67
commit
092100c6ae
@@ -851,6 +851,16 @@ M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away),
|
|||||||
</x-app-bar>
|
</x-app-bar>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`:actions` also takes a list, most used first; each entry takes `<x-menu-item>`'s props (`label`, `icon`, `link`, `external`, `no-wire-navigate`, `disabled`, `selected`) and passes every other key (`wire:click`, `x-on:click`) through as an attribute. The list overflows as M3's trailing actions do: at most two icon buttons below `medium` (600px) and four from it, the `more_vert` "More options" button counted among them, and the rest in a menu behind it. Pure CSS — both forms render, each width hides one — so no flash and no script. Every icon button is named and tooltipped by its `label`. Only the list overflows: markup in the `actions` slot is drawn as written, and a slot replaces a list. M3 still prefers a toolbar to an app bar full of actions.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-app-bar title="holiday-photos" :actions="[
|
||||||
|
['label' => 'Share', 'icon' => 'share', 'wire:click' => 'share'],
|
||||||
|
['label' => 'Download', 'icon' => 'download', 'link' => route('shares.download', $share)],
|
||||||
|
['label' => 'Rename', 'icon' => 'edit', 'x-on:click' => 'renaming = true'],
|
||||||
|
]" />
|
||||||
|
```
|
||||||
|
|
||||||
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
|
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
|
||||||
|
|
||||||
### `<x-toolbar>`
|
### `<x-toolbar>`
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
* negative top, so it scrolls up until only 64px of it remain, while its row of icons is sticky at
|
* negative top, so it scrolls up until only 64px of it remain, while its row of icons is sticky at
|
||||||
* 0 inside it and stays put — the big title scrolls away under the row. Script only says when that
|
* 0 inside it and stays put — the big title scrolls away under the row. Script only says when that
|
||||||
* has happened (resources/js/app-bar.js), so the small title can fade in and the colour change.
|
* has happened (resources/js/app-bar.js), so the small title can fade in and the colour change.
|
||||||
|
*
|
||||||
|
* Trailing actions given as a list collapse into an overflow menu below `medium` and come back
|
||||||
|
* from it, by media query alone: both forms of every action are in the page, and each width hides
|
||||||
|
* the one it does not use, so nothing flashes and no script counts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@@ -89,6 +93,29 @@
|
|||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `actions` as a list: every action is both an icon button and a menu item, and the width of
|
||||||
|
the window says which one is drawn — up to two trailing icon buttons below `medium` and four
|
||||||
|
from it, an overflow button counted among them
|
||||||
|
(docs/reference/m3/components-navigation-selection-inputs.md § Top app bar → Behaviour and
|
||||||
|
guidelines). The wrappers stay out of the row's layout until a width hides them. */
|
||||||
|
[data-app-bar-action],
|
||||||
|
[data-app-bar-overflow] {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 37.5rem) {
|
||||||
|
[data-app-bar-action][data-overflows-compact],
|
||||||
|
[data-app-bar-overflow="medium"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 37.5rem) {
|
||||||
|
[data-app-bar-overflow="compact"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-app-bar-headline] {
|
[data-app-bar-headline] {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -6,6 +6,25 @@
|
|||||||
title is an `<h1>` unless `heading` names another element. Slots: `navigation` (the leading
|
title is an `<h1>` unless `heading` names another element. Slots: `navigation` (the leading
|
||||||
icon button: back, or the menu) and `actions` (trailing icon buttons, an avatar).
|
icon button: back, or the menu) and `actions` (trailing icon buttons, an avatar).
|
||||||
|
|
||||||
|
`actions` can instead be a list, `:actions="[['label' => 'Share', 'icon' => 'share',
|
||||||
|
'wire:click' => 'share'], …]"`, in the order of use — M3 puts the most used nearest the
|
||||||
|
headline. Each entry takes what `<x-menu-item>` takes: `label` and `icon`, `link` (with
|
||||||
|
`external` or `no-wire-navigate`), `disabled`, `selected`, and any other key — `wire:click`,
|
||||||
|
`x-on:click` — as an attribute. Every entry is rendered twice, as an icon button named and
|
||||||
|
tooltipped by its label and as a menu item, and the window's width decides which one shows.
|
||||||
|
M3 allows "up to two icon buttons" after the headline, gives a larger screen "up to four
|
||||||
|
trailing icons" (said of the search app bar, the only count it gives for a wider window), and
|
||||||
|
lets actions at the trailing edge "collapse into an overflow menu at smaller breakpoints" and
|
||||||
|
"become visible again at larger sizes" (docs/reference/m3/components-navigation-selection-inputs.md
|
||||||
|
§ Top app bar → Behaviour and guidelines; app-bars/guidelines § Trailing icon buttons, § Large
|
||||||
|
screens, § Resizing). So below `medium` (600px) the bar shows at most two icon buttons and from
|
||||||
|
there at most four, the overflow button — `more_vert`, "More options" — counted among them:
|
||||||
|
with more actions than that, the first one (or three) stay and the rest go into its menu. Each
|
||||||
|
width has a menu of its own holding only what overflows there, so the arrow keys never land on
|
||||||
|
a hidden row, and it is all CSS (resources/css/components/app-bar.css): nothing moves after the
|
||||||
|
page has drawn. Only the list overflows; markup in the slot is drawn as written. A slot and a
|
||||||
|
list are alternatives — a slot of the same name replaces the attribute.
|
||||||
|
|
||||||
Sticky at the top by default (`:sticky="false"` for one that scrolls away), under the top safe
|
Sticky at the top by default (`:sticky="false"` for one that scrolls away), under the top safe
|
||||||
area; the surface turns surface-container once content scrolls under it
|
area; the surface turns surface-container once content scrolls under it
|
||||||
(resources/css/components/app-bar.css, resources/js/app-bar.js). A collapsing bar needs the
|
(resources/css/components/app-bar.css, resources/js/app-bar.js). A collapsing bar needs the
|
||||||
@@ -18,12 +37,36 @@
|
|||||||
'variant' => 'small',
|
'variant' => 'small',
|
||||||
'sticky' => true,
|
'sticky' => true,
|
||||||
'heading' => 'h1',
|
'heading' => 'h1',
|
||||||
|
'actions' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$variant = in_array($variant, ['small', 'center', 'medium', 'large', 'search'], true) ? $variant : 'small';
|
$variant = in_array($variant, ['small', 'center', 'medium', 'large', 'search'], true) ? $variant : 'small';
|
||||||
$flexible = in_array($variant, ['medium', 'large'], true);
|
$flexible = in_array($variant, ['medium', 'large'], true);
|
||||||
$heading = in_array($heading, ['h1', 'h2', 'h3', 'div', 'p'], true) ? $heading : 'h1';
|
$heading = in_array($heading, ['h1', 'h2', 'h3', 'div', 'p'], true) ? $heading : 'h1';
|
||||||
|
|
||||||
|
// The list form of `actions`, each entry split into the props an icon button and a menu item
|
||||||
|
// share and the attributes (its action) both of them carry.
|
||||||
|
$list = is_iterable($actions) ? collect($actions)->values()->map(fn (array $action): array => [
|
||||||
|
'label' => $action['label'] ?? null,
|
||||||
|
'icon' => $action['icon'] ?? null,
|
||||||
|
'link' => $action['link'] ?? null,
|
||||||
|
'external' => (bool) ($action['external'] ?? false),
|
||||||
|
'noWireNavigate' => (bool) ($action['noWireNavigate'] ?? $action['no-wire-navigate'] ?? false),
|
||||||
|
'disabled' => (bool) ($action['disabled'] ?? false),
|
||||||
|
'selected' => $action['selected'] ?? null,
|
||||||
|
'attributes' => new \Illuminate\View\ComponentAttributeBag(\Illuminate\Support\Arr::except($action, [
|
||||||
|
'label', 'icon', 'link', 'external', 'noWireNavigate', 'no-wire-navigate', 'disabled', 'selected',
|
||||||
|
])),
|
||||||
|
])->all() : null;
|
||||||
|
|
||||||
|
// How many stay icon buttons: two trailing icon buttons below `medium`, four from it, the
|
||||||
|
// overflow button one of them whenever it is there.
|
||||||
|
$count = count($list ?? []);
|
||||||
|
$shown = [
|
||||||
|
'compact' => $count > 2 ? 1 : $count,
|
||||||
|
'medium' => $count > 4 ? 3 : $count,
|
||||||
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<header
|
<header
|
||||||
@@ -57,9 +100,55 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@isset($actions)
|
@if ($list !== null)
|
||||||
|
@if ($count > 0)
|
||||||
|
<div data-app-bar-trailing>
|
||||||
|
@foreach ($list as $index => $action)
|
||||||
|
@if ($index < $shown['medium'])
|
||||||
|
<span data-app-bar-action @if ($index >= $shown['compact']) data-overflows-compact @endif>
|
||||||
|
<x-livewire-material::button
|
||||||
|
:icon="$action['icon']"
|
||||||
|
:tooltip-bottom="$action['label']"
|
||||||
|
:link="$action['link']"
|
||||||
|
:external="$action['external']"
|
||||||
|
:no-wire-navigate="$action['noWireNavigate']"
|
||||||
|
:disabled="$action['disabled']"
|
||||||
|
:selected="$action['selected']"
|
||||||
|
:attributes="$action['attributes']"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@foreach ($shown as $width => $kept)
|
||||||
|
@if ($count > $kept)
|
||||||
|
<span data-app-bar-overflow="{{ $width }}">
|
||||||
|
<x-livewire-material::menu :label="__('More options')" position="bottom-end">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-livewire-material::button icon="more_vert" :tooltip-bottom="__('More options')" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
@foreach (array_slice($list, $kept) as $action)
|
||||||
|
<x-livewire-material::menu-item
|
||||||
|
:label="$action['label']"
|
||||||
|
:icon="$action['icon']"
|
||||||
|
:link="$action['link']"
|
||||||
|
:external="$action['external']"
|
||||||
|
:no-wire-navigate="$action['noWireNavigate']"
|
||||||
|
:disabled="$action['disabled']"
|
||||||
|
:selected="$action['selected']"
|
||||||
|
:attributes="$action['attributes']"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</x-livewire-material::menu>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif (isset($actions))
|
||||||
<div data-app-bar-trailing>{{ $actions }}</div>
|
<div data-app-bar-trailing>{{ $actions }}</div>
|
||||||
@endisset
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($flexible && filled($title))
|
@if ($flexible && filled($title))
|
||||||
|
|||||||
@@ -7,11 +7,24 @@
|
|||||||
<x-slot:navigation><x-button icon="menu" tooltip="Open navigation" /></x-slot:navigation>
|
<x-slot:navigation><x-button icon="menu" tooltip="Open navigation" /></x-slot:navigation>
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button icon="search" tooltip="Search" />
|
<x-button icon="search" tooltip="Search" />
|
||||||
<x-button icon="more_vert" tooltip="More" />
|
<x-button icon="filter_list" tooltip="Filter" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-app-bar>
|
</x-app-bar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Resize the window: two icon buttons below 600px, four from there, the rest in "More options". --}}
|
||||||
|
<div>
|
||||||
|
<x-app-bar title="holiday-photos" :sticky="false" :actions="[
|
||||||
|
['label' => 'Share', 'icon' => 'share'],
|
||||||
|
['label' => 'Download', 'icon' => 'download'],
|
||||||
|
['label' => 'Star', 'icon' => 'star'],
|
||||||
|
['label' => 'Rename', 'icon' => 'edit'],
|
||||||
|
['label' => 'Details', 'icon' => 'info', 'link' => '#bars', 'no-wire-navigate' => true],
|
||||||
|
]">
|
||||||
|
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
|
||||||
|
</x-app-bar>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-app-bar variant="center" title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" :sticky="false">
|
<x-app-bar variant="center" title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" :sticky="false">
|
||||||
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
|
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
|
||||||
|
|||||||
@@ -44,6 +44,87 @@ it('falls back to a small bar and an h1 for unknown values', function () {
|
|||||||
->toContain('<h1 data-app-bar-title>');
|
->toContain('<h1 data-app-bar-title>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps two trailing icon buttons below medium and four from it, the rest in an overflow menu', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-app-bar title="Shares" :actions="[
|
||||||
|
['label' => 'Search', 'icon' => 'search', 'x-on:click' => 'find()'],
|
||||||
|
['label' => 'Share', 'icon' => 'share', 'link' => '/shares/1/share'],
|
||||||
|
['label' => 'Star', 'icon' => 'star', 'selected' => true, 'wire:click' => 'star'],
|
||||||
|
['label' => 'Archive', 'icon' => 'archive', 'wire:click' => 'archive'],
|
||||||
|
['label' => 'Delete', 'icon' => 'delete', 'disabled' => true],
|
||||||
|
]" />
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
preg_match('/data-app-bar-overflow="compact">(.*?)data-app-bar-overflow="medium">(.*)$/s', $html, $menus);
|
||||||
|
[, $compact, $medium] = $menus + [null, '', ''];
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
// M3's "up to four trailing icons" on a larger screen, the overflow button one of them: the
|
||||||
|
// first three are icon buttons, and the first stays one on a compact window too, beside the
|
||||||
|
// overflow button — M3's "up to two icon buttons" (navigation reference § Top app bar).
|
||||||
|
->and(substr_count($html, '<span data-app-bar-action'))->toBe(3)
|
||||||
|
->and(substr_count($html, 'data-overflows-compact'))->toBe(2)
|
||||||
|
->and($html)->toMatch('/<span data-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
|
||||||
|
->toMatch('/<span data-app-bar-action\s+data-overflows-compact\s*>\s*<a href="\/shares\/1\/share"[^>]*aria-label="Share"/')
|
||||||
|
->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/')
|
||||||
|
// Every icon button is named, tooltipped and reaches 48px.
|
||||||
|
->toMatch('/<button[^>]*aria-label="Search"[^>]*class="[^"]*touch-target/')
|
||||||
|
->toMatch('/popover="manual"[^>]*>\s*Search<\/span>/')
|
||||||
|
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
|
||||||
|
// One overflow button per width, named and tooltipped.
|
||||||
|
->and(preg_match_all('/<button[^>]*aria-label="More options"[^>]*class="[^"]*touch-target/', $html))->toBe(2)
|
||||||
|
->and(preg_match_all('/role="menu"\s+data-menu\s+aria-label="More options"/', $html))->toBe(2)
|
||||||
|
// Below medium: all but the first.
|
||||||
|
->and($compact)
|
||||||
|
->toMatch('/<a role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
|
||||||
|
->toMatch('/role="menuitemcheckbox" aria-checked="true"[^>]*wire:click="star"/')
|
||||||
|
->toMatch('/role="menuitem"[^>]*wire:click="archive"/')
|
||||||
|
->toMatch('/role="menuitem" aria-disabled="true"/')
|
||||||
|
->not->toContain('find()')
|
||||||
|
// From medium: only what the four do not hold.
|
||||||
|
->and($medium)
|
||||||
|
->toContain('Archive')
|
||||||
|
->toContain('Delete')
|
||||||
|
->not->toContain('Star')
|
||||||
|
->not->toContain('/shares/1/share')
|
||||||
|
// The action goes with the entry to both of its forms.
|
||||||
|
->and(substr_count($html, 'wire:click="star"'))->toBe(2)
|
||||||
|
->and(substr_count($html, 'wire:click="archive"'))->toBe(2)
|
||||||
|
->and(substr_count($html, 'x-on:click="find()"'))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds an overflow menu only at the widths something overflows', function () {
|
||||||
|
$actions = fn (int $count): array => collect(['search', 'share', 'star', 'archive', 'delete'])
|
||||||
|
->take($count)
|
||||||
|
->map(fn (string $icon): array => ['label' => ucfirst($icon), 'icon' => $icon])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$two = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(2)]);
|
||||||
|
$four = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(4)]);
|
||||||
|
|
||||||
|
expect($two)
|
||||||
|
->not->toContain('data-app-bar-overflow')
|
||||||
|
->not->toContain('data-overflows-compact')
|
||||||
|
->not->toContain('More options')
|
||||||
|
->and(substr_count($two, '<span data-app-bar-action'))->toBe(2)
|
||||||
|
->and($four)
|
||||||
|
->toContain('data-app-bar-overflow="compact"')
|
||||||
|
->not->toContain('data-app-bar-overflow="medium"')
|
||||||
|
->and(substr_count($four, '<span data-app-bar-action'))->toBe(4)
|
||||||
|
->and(substr_count($four, 'data-overflows-compact'))->toBe(3)
|
||||||
|
// An empty list draws no trailing area; markup in the slot is drawn as written, never
|
||||||
|
// overflowing.
|
||||||
|
->and((string) $this->blade('<x-app-bar title="Shares" :actions="[]" />'))
|
||||||
|
->not->toContain('data-app-bar-trailing')
|
||||||
|
->and((string) $this->blade('<x-app-bar title="Shares"><x-slot:actions><button>a</button><button>b</button><button>c</button></x-slot:actions></x-app-bar>'))
|
||||||
|
->toContain('<div data-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
|
||||||
|
->not->toContain('data-app-bar-overflow');
|
||||||
|
|
||||||
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
|
||||||
|
->toMatch('/@media \(width < 37\.5rem\) \{\s+\[data-app-bar-action\]\[data-overflows-compact\],\s+\[data-app-bar-overflow="medium"\] \{\s+display: none;/')
|
||||||
|
->toMatch('/@media \(width >= 37\.5rem\) \{\s+\[data-app-bar-overflow="compact"\] \{\s+display: none;/');
|
||||||
|
});
|
||||||
|
|
||||||
it('draws floating and docked toolbars', function () {
|
it('draws floating and docked toolbars', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-toolbar label="Formatting" vibrant vertical place="end">
|
<x-toolbar label="Formatting" vibrant vertical place="end">
|
||||||
|
|||||||
Reference in New Issue
Block a user