diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 9379edcc..7b1f7f17 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -851,6 +851,16 @@ M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), ``` +`:actions` also takes a list, most used first; each entry takes ``'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 + +``` + A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine). ### `` diff --git a/resources/css/components/app-bar.css b/resources/css/components/app-bar.css index f1ce615c..cf92a5fd 100644 --- a/resources/css/components/app-bar.css +++ b/resources/css/components/app-bar.css @@ -15,6 +15,10 @@ * 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 * 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 { @@ -89,6 +93,29 @@ 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] { display: flex; min-width: 0; diff --git a/resources/views/components/app-bar.blade.php b/resources/views/components/app-bar.blade.php index 6e23d100..403fd8a5 100644 --- a/resources/views/components/app-bar.blade.php +++ b/resources/views/components/app-bar.blade.php @@ -6,6 +6,25 @@ title is an `

` unless `heading` names another element. Slots: `navigation` (the leading 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 `` 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 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 @@ -18,12 +37,36 @@ 'variant' => 'small', 'sticky' => true, 'heading' => 'h1', + 'actions' => null, ]) @php $variant = in_array($variant, ['small', 'center', 'medium', 'large', 'search'], true) ? $variant : 'small'; $flexible = in_array($variant, ['medium', 'large'], true); $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
@endif - @isset($actions) + @if ($list !== null) + @if ($count > 0) +
+ @foreach ($list as $index => $action) + @if ($index < $shown['medium']) + = $shown['compact']) data-overflows-compact @endif> + + + @endif + @endforeach + + @foreach ($shown as $width => $kept) + @if ($count > $kept) + + + + + + + @foreach (array_slice($list, $kept) as $action) + + @endforeach + + + @endif + @endforeach +
+ @endif + @elseif (isset($actions))
{{ $actions }}
- @endisset + @endif @if ($flexible && filled($title)) diff --git a/resources/views/showcase/sections/bars.blade.php b/resources/views/showcase/sections/bars.blade.php index a0ac1557..668baf6c 100644 --- a/resources/views/showcase/sections/bars.blade.php +++ b/resources/views/showcase/sections/bars.blade.php @@ -7,11 +7,24 @@ - + + {{-- Resize the window: two icon buttons below 600px, four from there, the rest in "More options". --}} +
+ + + +
+
diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index ca15940a..6049b773 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -44,6 +44,87 @@ it('falls back to a small bar and an h1 for unknown values', function () { ->toContain('

'); }); +it('keeps two trailing icon buttons below medium and four from it, the rest in an overflow menu', function () { + $html = (string) $this->blade(<<<'BLADE' + + 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, 'toBe(3) + ->and(substr_count($html, 'data-overflows-compact'))->toBe(2) + ->and($html)->toMatch('/\s*]*aria-label="Search"/') + ->toMatch('/\s*]*aria-label="Share"/') + ->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/') + // Every icon button is named, tooltipped and reaches 48px. + ->toMatch('/]*aria-label="Search"[^>]*class="[^"]*touch-target/') + ->toMatch('/popover="manual"[^>]*>\s*Search<\/span>/') + ->toMatch('/]*aria-label="Star" aria-pressed="true"/') + // One overflow button per width, named and tooltipped. + ->and(preg_match_all('/]*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('/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('', ['actions' => $actions(2)]); + $four = (string) $this->blade('', ['actions' => $actions(4)]); + + expect($two) + ->not->toContain('data-app-bar-overflow') + ->not->toContain('data-overflows-compact') + ->not->toContain('More options') + ->and(substr_count($two, 'toBe(2) + ->and($four) + ->toContain('data-app-bar-overflow="compact"') + ->not->toContain('data-app-bar-overflow="medium"') + ->and(substr_count($four, '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('')) + ->not->toContain('data-app-bar-trailing') + ->and((string) $this->blade('')) + ->toContain('
') + ->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 () { $html = (string) $this->blade(<<<'BLADE'