Draw the app bar without Tailwind

Plan step 36 (navigation group, first stream): <x-app-bar>'s class
lists move into resources/css/components/app-bar.css, keyed on
data-md-app-bar (with data-md-variant/-sticky/-subtitled/-scrolled/
-collapsed) and its parts data-md-app-bar-row/-leading/-headline/
-title/-subtitle/-search/-trailing/-action/-overflow/-expanded. The
overflow menu (two icon buttons below 600px, four from it, N-09's
312px search cap and N-11's three-column centred row) already matched
the audit; only its hooks, units and layer needed to change. Every
length is px, spacing through the measurement tokens where one
matches (M3's own "4dp leading/trailing space" and "16dp" headline
inset had no exact token, so those stay literal). Imports button.css,
menu.css and menu-item.css for the overflow menu the view renders.

Hooks renamed: every unprefixed data-app-bar-* attribute and
data-scrolled/data-collapsed to data-md-*, updated in
tests/Feature/Components/{AppBarTest,PaneTest}.php and
tests/Browser/{BarsTest,NavigationTest}.php. app-bar.js needed no
change: it reads the bar through $root, not by hook name.

tests/Feature/Components/NavigationStylesheetsTest.php is the
navigation twin of ContainmentStylesheetsTest.php, on the same
tests/Support/ViewClasses.php rule; its dataset starts at 'app-bar'
and a 'tabs' entry (once that commit lands) will read both
tabs.blade.php and tab.blade.php, since they share one stylesheet.

Added the owed browser tests (docs/plans/material-3-browser-tests.md):
the overflow at 599/600px, keyboard in its menu, a menu closing once
its width no longer shows it, and a wire:click action from both the
icon button and the menu item form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 23:21:54 +02:00
co-authored by Claude Sonnet 5
parent b7f120af98
commit b7641a35c2
9 changed files with 437 additions and 155 deletions
+1
View File
@@ -69,3 +69,4 @@
@import './components/error-page.css'; @import './components/error-page.css';
/* Navigation */ /* Navigation */
@import './components/app-bar.css';
+92 -70
View File
@@ -1,14 +1,25 @@
/* /*
* M3 Expressive's top app bars (AppBarTokens, AppBarSmallTokens, AppBarMediumFlexibleTokens, * `<x-app-bar>`: M3 Expressive's top app bars (AppBarTokens.kt, AppBarSmallTokens.kt,
* AppBarLargeFlexibleTokens, androidx Compose Material 3, Apache-2.0). * AppBarMediumFlexibleTokens.kt, AppBarLargeFlexibleTokens.kt, androidx Compose Material 3,
* Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md § Top App Bar).
*
* [data-md-app-bar] the bar; data-md-variant, data-md-sticky, data-md-subtitled,
* data-md-scrolled/data-md-collapsed (resources/js/app-bar.js)
* [data-md-app-bar-row] 64px, sticky at the top of the bar
* [data-md-app-bar-leading] the `navigation` slot
* [data-md-app-bar-headline] [data-md-app-bar-title], [data-md-app-bar-subtitle]
* [data-md-app-bar-search] the `search` variant's slot
* [data-md-app-bar-trailing] [data-md-app-bar-action], [data-md-app-bar-overflow]
* [data-md-app-bar-expanded] medium/large's big title, under the row
* *
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px * small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
* from the start or straight after the navigation icon * from the start or straight after the navigation icon
* center the same, the title centred — a three-column grid, so it shrinks against whatever is * center the same, the title centred — a three-column grid, so it shrinks against whatever is
* beside it rather than ellipsising underneath it * beside it rather than ellipsising underneath it (N-11: a fixed inset fit exactly one
* of M3's two trailing buttons)
* medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title * medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title
* large 120px (152px with a subtitle): a display-small title * large 120px (152px with a subtitle): a display-small title
* search the row holds a search bar, full width to 312px and then half of what is left * search the row holds a search bar, full width to 312px and then half of what is left (N-09)
* *
* The container is the surface, and surface-container once content scrolls under it. A medium or * The container is the surface, and surface-container once content scrolls under it. A medium or
* large bar collapses into the small one without script moving anything: the bar is sticky at a * large bar collapses into the small one without script moving anything: the bar is sticky at a
@@ -17,13 +28,22 @@
* 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 * 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 * from it, by media query alone (the site's "trailing actions collapse into an overflow menu at
* the one it does not use, so nothing flashes and no script counts. * smaller breakpoints" and "become visible again at larger sizes", read together with "up to two
* icon buttons" and, for a wider window, "up to four trailing icons"): 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 material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
[data-app-bar] {
--app-bar-row: 4rem; @import './button.css';
@import './menu.css';
@import './menu-item.css';
@layer material.components {
[data-md-app-bar] {
--app-bar-row: 64px;
--app-bar-height: var(--app-bar-row); --app-bar-height: var(--app-bar-row);
z-index: 20; z-index: 20;
@@ -32,40 +52,42 @@
padding-top: var(--material-safe-top, env(safe-area-inset-top)); padding-top: var(--material-safe-top, env(safe-area-inset-top));
background-color: var(--md-sys-color-surface); background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default); transition-property: background-color;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
} }
[data-app-bar][data-variant="medium"] { [data-md-app-bar][data-md-variant='medium'] {
--app-bar-height: 7rem; --app-bar-height: 112px;
} }
[data-app-bar][data-variant="medium"][data-subtitled] { [data-md-app-bar][data-md-variant='medium'][data-md-subtitled] {
--app-bar-height: 8.5rem; --app-bar-height: 136px;
} }
[data-app-bar][data-variant="large"] { [data-md-app-bar][data-md-variant='large'] {
--app-bar-height: 7.5rem; --app-bar-height: 120px;
} }
[data-app-bar][data-variant="large"][data-subtitled] { [data-md-app-bar][data-md-variant='large'][data-md-subtitled] {
--app-bar-height: 9.5rem; --app-bar-height: 152px;
} }
[data-app-bar][data-sticky] { [data-md-app-bar][data-md-sticky] {
position: sticky; position: sticky;
top: 0; top: 0;
} }
/* `--app-bar-measured` is the bar's real height (a title can wrap), set by app-bar.js. */ /* `--app-bar-measured` is the bar's real height (a title can wrap), set by app-bar.js. */
[data-app-bar][data-sticky]:is([data-variant="medium"], [data-variant="large"]) { [data-md-app-bar][data-md-sticky]:is([data-md-variant='medium'], [data-md-variant='large']) {
top: calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height))); top: calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height)));
} }
[data-app-bar][data-scrolled] { [data-md-app-bar][data-md-scrolled] {
background-color: var(--md-sys-color-surface-container); background-color: var(--md-sys-color-surface-container);
} }
[data-app-bar-row] { [data-md-app-bar-row] {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
@@ -73,63 +95,62 @@
align-items: center; align-items: center;
gap: 0; gap: 0;
min-height: var(--app-bar-row); min-height: var(--app-bar-row);
padding-inline: 0.25rem; padding-inline: var(--md-sys-measurement-space50);
background-color: inherit; background-color: inherit;
} }
[data-app-bar-leading], [data-md-app-bar-leading],
[data-app-bar-trailing] { [data-md-app-bar-trailing] {
display: flex; display: flex;
flex: none; flex: none;
align-items: center; align-items: center;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
} }
[data-app-bar-leading] { [data-md-app-bar-leading] {
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
} }
[data-app-bar-trailing] { [data-md-app-bar-trailing] {
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 /* `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 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 from it, an overflow button counted among them. The wrappers stay out of the row's layout
(docs/reference/m3/components-navigation-selection-inputs.md § Top app bar → Behaviour and until a width hides them. */
guidelines). The wrappers stay out of the row's layout until a width hides them. */ [data-md-app-bar-action],
[data-app-bar-action], [data-md-app-bar-overflow] {
[data-app-bar-overflow] {
display: contents; display: contents;
} }
@media (width < 37.5rem) { @media (width < 600px) {
[data-app-bar-action][data-overflows-compact], [data-md-app-bar-action][data-md-overflows-compact],
[data-app-bar-overflow="medium"] { [data-md-app-bar-overflow='medium'] {
display: none; display: none;
} }
} }
@media (width >= 37.5rem) { @media (width >= 600px) {
[data-app-bar-overflow="compact"] { [data-md-app-bar-overflow='compact'] {
display: none; display: none;
} }
} }
[data-app-bar-headline] { [data-md-app-bar-headline] {
display: flex; display: flex;
min-width: 0; min-width: 0;
flex: 1 1 0%; flex: 1 1 0%;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding-inline: 0.75rem; padding-inline: 12px;
} }
[data-app-bar-row]:has([data-app-bar-leading]) [data-app-bar-headline] { [data-md-app-bar-row]:has([data-md-app-bar-leading]) [data-md-app-bar-headline] {
padding-inline-start: 0.25rem; padding-inline-start: var(--md-sys-measurement-space50);
} }
[data-app-bar-title] { [data-md-app-bar-title] {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -137,7 +158,7 @@
letter-spacing: var(--md-sys-typescale-title-lg-tracking); letter-spacing: var(--md-sys-typescale-title-lg-tracking);
} }
[data-app-bar-subtitle] { [data-md-app-bar-subtitle] {
overflow: hidden; overflow: hidden;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -148,91 +169,92 @@
/* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the /* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the
window and shrinks against whatever is actually beside it. M3 allows two trailing icon window and shrinks against whatever is actually beside it. M3 allows two trailing icon
buttons, which a fixed inset for one would have let a long title ellipsise underneath. */ buttons, which a fixed inset for one would have let a long title ellipsise underneath (N-11). */
[data-app-bar][data-variant="center"] [data-app-bar-row] { [data-md-app-bar][data-md-variant='center'] [data-md-app-bar-row] {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
} }
[data-app-bar][data-variant="center"] [data-app-bar-leading] { [data-md-app-bar][data-md-variant='center'] [data-md-app-bar-leading] {
grid-column: 1; grid-column: 1;
justify-self: start; justify-self: start;
} }
[data-app-bar][data-variant="center"] [data-app-bar-headline] { [data-md-app-bar][data-md-variant='center'] [data-md-app-bar-headline] {
grid-column: 2; grid-column: 2;
min-width: 0; min-width: 0;
align-items: center; align-items: center;
justify-self: center; justify-self: center;
padding-inline: 0.75rem; padding-inline: 12px;
text-align: center; text-align: center;
} }
[data-app-bar][data-variant="center"] [data-app-bar-trailing] { [data-md-app-bar][data-md-variant='center'] [data-md-app-bar-trailing] {
grid-column: 3; grid-column: 3;
justify-self: end; justify-self: end;
margin-inline-start: 0; margin-inline-start: 0;
} }
/* The search bar fills the space between the leading and trailing elements until it is 312dp /* The search bar fills the space between the leading and trailing elements until it is 312dp
wide, and then grows into only half of what is left wide, and then grows into only half of what is left (N-09). The percentage is of the row
(docs/reference/m3/components-navigation-selection-inputs.md § Top app bar). The percentage rather than of that space, which CSS cannot name without measuring it; below 312px the cap
is of the row rather than of that space, which CSS cannot name without measuring it; below never binds, so a phone still gets the whole row. */
312px the cap never binds, so a phone still gets the whole row. */ [data-md-app-bar-search] {
[data-app-bar-search] {
min-width: 0; min-width: 0;
max-width: calc(19.5rem + (100% - 19.5rem) / 2); max-width: calc(312px + (100% - 312px) / 2);
flex: 1 1 0%; flex: 1 1 0%;
margin-inline: auto; margin-inline: auto;
padding-inline: 0.25rem; padding-inline: var(--md-sys-measurement-space50);
} }
/* Medium and large: the row's title is the collapsed one, shown once the big one has gone. */ /* Medium and large: the row's title is the collapsed one, shown once the big one has gone. */
[data-app-bar]:is([data-variant="medium"], [data-variant="large"]) [data-app-bar-row] [data-app-bar-headline] { [data-md-app-bar]:is([data-md-variant='medium'], [data-md-variant='large']) [data-md-app-bar-row] [data-md-app-bar-headline] {
opacity: 0; opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
} }
[data-app-bar][data-collapsed] [data-app-bar-row] [data-app-bar-headline] { [data-md-app-bar][data-md-collapsed] [data-md-app-bar-row] [data-md-app-bar-headline] {
opacity: 1; opacity: 1;
} }
/* Heights from the tokens: the title (and subtitle) lines and the space under them fill what the /* Heights from the tokens: the title (and subtitle) lines and the space under them fill what the
row leaves — 36 + 12px medium, 36 + 20 + 16px with a subtitle; 44 + 12px large, 44 + 24 + 20px. */ row leaves — 36 + 12px medium, 36 + 20 + 16px with a subtitle; 44 + 12px large, 44 + 24 + 20px. */
[data-app-bar-expanded] { [data-md-app-bar-expanded] {
display: flex; display: flex;
min-height: calc(var(--app-bar-height) - var(--app-bar-row)); min-height: calc(var(--app-bar-height) - var(--app-bar-row));
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
padding-inline: 1rem; padding-inline: var(--md-sys-measurement-space200);
padding-bottom: 0.75rem; padding-bottom: 12px;
} }
[data-app-bar][data-subtitled] [data-app-bar-expanded] { [data-md-app-bar][data-md-subtitled] [data-md-app-bar-expanded] {
padding-bottom: 1rem; padding-bottom: var(--md-sys-measurement-space200);
} }
[data-app-bar][data-variant="large"][data-subtitled] [data-app-bar-expanded] { [data-md-app-bar][data-md-variant='large'][data-md-subtitled] [data-md-app-bar-expanded] {
padding-bottom: 1.25rem; padding-bottom: 20px;
} }
[data-app-bar-expanded] [data-app-bar-title] { [data-md-app-bar-expanded] [data-md-app-bar-title] {
white-space: normal; white-space: normal;
font: var(--md-sys-typescale-headline-md); font: var(--md-sys-typescale-headline-md);
letter-spacing: var(--md-sys-typescale-headline-md-tracking); letter-spacing: var(--md-sys-typescale-headline-md-tracking);
} }
[data-app-bar-expanded] [data-app-bar-subtitle] { [data-md-app-bar-expanded] [data-md-app-bar-subtitle] {
font: var(--md-sys-typescale-label-lg); font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking); letter-spacing: var(--md-sys-typescale-label-lg-tracking);
} }
[data-app-bar][data-variant="large"] [data-app-bar-expanded] [data-app-bar-title] { [data-md-app-bar][data-md-variant='large'] [data-md-app-bar-expanded] [data-md-app-bar-title] {
font: var(--md-sys-typescale-display-sm); font: var(--md-sys-typescale-display-sm);
letter-spacing: var(--md-sys-typescale-display-sm-tracking); letter-spacing: var(--md-sys-typescale-display-sm-tracking);
} }
[data-app-bar][data-variant="large"] [data-app-bar-expanded] [data-app-bar-subtitle] { [data-md-app-bar][data-md-variant='large'] [data-md-app-bar-expanded] [data-md-app-bar-subtitle] {
font: var(--md-sys-typescale-title-md); font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking); letter-spacing: var(--md-sys-typescale-title-md-tracking);
} }
-1
View File
@@ -19,6 +19,5 @@
/* Navigation — leaving in step 36 */ /* Navigation — leaving in step 36 */
@import './components/tabs.css'; @import './components/tabs.css';
@import './components/app-bar.css';
@import './components/navigation.css'; @import './components/navigation.css';
@import './components/toolbar.css'; @import './components/toolbar.css';
+38 -29
View File
@@ -16,20 +16,29 @@
trailing icons" (said of the search app bar, the only count it gives for a wider window), and 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 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 "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 § Top App Bar Behaviour and guidelines). So below `medium` (600px) the bar shows at most two
screens, § Resizing). So below `medium` (600px) the bar shows at most two icon buttons and from icon buttons and from there at most four, the overflow button `more_vert`, "More options"
there at most four, the overflow button `more_vert`, "More options" counted among them: counted among them: with more actions than that, the first one (or three) stay and the rest go
with more actions than that, the first one (or three) stay and the rest go into its menu. Each into its menu. Each width has a menu of its own holding only what overflows there, so the
width has a menu of its own holding only what overflows there, so the arrow keys never land on arrow keys never land on a hidden row, and it is all CSS (resources/css/components/app-bar.css):
a hidden row, and it is all CSS (resources/css/components/app-bar.css): nothing moves after the nothing moves after the page has drawn. Only the list overflows; markup in the slot is drawn
page has drawn. Only the list overflows; markup in the slot is drawn as written. A slot and a as written. A slot and a list are alternatives a slot of the same name replaces the
list are alternatives a slot of the same name replaces the attribute. 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
window to be what scrolls: an ancestor with `overflow: hidden` or `auto` would stop it window to be what scrolls: an ancestor with `overflow: hidden` or `auto` would stop it
sticking (`overflow-x: clip` is fine). --}} sticking (`overflow-x: clip` is fine).
The root renders `data-md-app-bar` with `data-md-variant`, `data-md-sticky`,
`data-md-subtitled`, and `data-md-scrolled`/`data-md-collapsed` once the page has scrolled
under it; its row is `data-md-app-bar-row` holding `data-md-app-bar-leading`,
`data-md-app-bar-headline` (`data-md-app-bar-title`, `data-md-app-bar-subtitle`),
`data-md-app-bar-search` and `data-md-app-bar-trailing` (`data-md-app-bar-action`,
`data-md-app-bar-overflow`); a flexible bar's big title is `data-md-app-bar-expanded`. Drawn
by resources/css/components/app-bar.css, which imports button.css, menu.css and menu-item.css
for what the view renders. --}}
@props([ @props([
'title' => null, 'title' => null,
@@ -71,30 +80,30 @@
<header <header
x-data="materialAppBar" x-data="materialAppBar"
x-bind:data-scrolled="scrolled ? '' : null" x-bind:data-md-scrolled="scrolled ? '' : null"
x-bind:data-collapsed="collapsed ? '' : null" x-bind:data-md-collapsed="collapsed ? '' : null"
x-bind:style="measured" x-bind:style="measured"
data-app-bar data-md-app-bar
data-variant="{{ $variant }}" data-md-variant="{{ $variant }}"
@if ($sticky) data-sticky @endif @if ($sticky) data-md-sticky @endif
@if (filled($subtitle)) data-subtitled @endif @if (filled($subtitle)) data-md-subtitled @endif
{{ $attributes }} {{ $attributes }}
> >
<div data-app-bar-row> <div data-md-app-bar-row>
@isset($navigation) @isset($navigation)
<div data-app-bar-leading>{{ $navigation }}</div> <div data-md-app-bar-leading>{{ $navigation }}</div>
@endisset @endisset
@if ($variant === 'search') @if ($variant === 'search')
<div data-app-bar-search>{{ $slot }}</div> <div data-md-app-bar-search>{{ $slot }}</div>
@elseif (filled($title)) @elseif (filled($title))
<div data-app-bar-headline @if ($flexible) aria-hidden="true" @endif> <div data-md-app-bar-headline @if ($flexible) aria-hidden="true" @endif>
@if ($flexible) @if ($flexible)
<span data-app-bar-title>{{ $title }}</span> <span data-md-app-bar-title>{{ $title }}</span>
@else @else
<{{ $heading }} data-app-bar-title>{{ $title }}</{{ $heading }}> <{{ $heading }} data-md-app-bar-title>{{ $title }}</{{ $heading }}>
@if (filled($subtitle)) @if (filled($subtitle))
<p data-app-bar-subtitle>{{ $subtitle }}</p> <p data-md-app-bar-subtitle>{{ $subtitle }}</p>
@endif @endif
@endif @endif
</div> </div>
@@ -102,10 +111,10 @@
@if ($list !== null) @if ($list !== null)
@if ($count > 0) @if ($count > 0)
<div data-app-bar-trailing> <div data-md-app-bar-trailing>
@foreach ($list as $index => $action) @foreach ($list as $index => $action)
@if ($index < $shown['medium']) @if ($index < $shown['medium'])
<span data-app-bar-action @if ($index >= $shown['compact']) data-overflows-compact @endif> <span data-md-app-bar-action @if ($index >= $shown['compact']) data-md-overflows-compact @endif>
<x-livewire-material::button <x-livewire-material::button
:icon="$action['icon']" :icon="$action['icon']"
:tooltip-bottom="$action['label']" :tooltip-bottom="$action['label']"
@@ -122,7 +131,7 @@
@foreach ($shown as $width => $kept) @foreach ($shown as $width => $kept)
@if ($count > $kept) @if ($count > $kept)
<span data-app-bar-overflow="{{ $width }}"> <span data-md-app-bar-overflow="{{ $width }}">
<x-livewire-material::menu :label="__('More options')" position="bottom-end"> <x-livewire-material::menu :label="__('More options')" position="bottom-end">
<x-slot:trigger> <x-slot:trigger>
<x-livewire-material::button icon="more_vert" :tooltip-bottom="__('More options')" /> <x-livewire-material::button icon="more_vert" :tooltip-bottom="__('More options')" />
@@ -147,15 +156,15 @@
</div> </div>
@endif @endif
@elseif (isset($actions)) @elseif (isset($actions))
<div data-app-bar-trailing>{{ $actions }}</div> <div data-md-app-bar-trailing>{{ $actions }}</div>
@endif @endif
</div> </div>
@if ($flexible && filled($title)) @if ($flexible && filled($title))
<div data-app-bar-expanded> <div data-md-app-bar-expanded>
<{{ $heading }} data-app-bar-title>{{ $title }}</{{ $heading }}> <{{ $heading }} data-md-app-bar-title>{{ $title }}</{{ $heading }}>
@if (filled($subtitle)) @if (filled($subtitle))
<p data-app-bar-subtitle>{{ $subtitle }}</p> <p data-md-app-bar-subtitle>{{ $subtitle }}</p>
@endif @endif
</div> </div>
@endif @endif
+106 -5
View File
@@ -90,22 +90,22 @@ it('moves between tabs with the arrow keys, skipping disabled ones, and tells Li
}); });
it('collapses a medium app bar into its row as the page scrolls, and fades its small title in', function () { it('collapses a medium app bar into its row as the page scrolls, and fades its small title in', function () {
$headline = "getComputedStyle(document.querySelector('#bar [data-app-bar-row] [data-app-bar-headline]')).opacity"; $headline = "getComputedStyle(document.querySelector('#bar [data-md-app-bar-row] [data-md-app-bar-headline]')).opacity";
$page = barsProbe() $page = barsProbe()
->assertScript("! document.querySelector('#bar').hasAttribute('data-collapsed')") ->assertScript("! document.querySelector('#bar').hasAttribute('data-md-collapsed')")
->assertScript("{$headline} === '0'"); ->assertScript("{$headline} === '0'");
$page->script('window.scrollTo(0, 600)'); $page->script('window.scrollTo(0, 600)');
$page->assertScript("document.querySelector('#bar').hasAttribute('data-collapsed') && document.querySelector('#bar').hasAttribute('data-scrolled')") $page->assertScript("document.querySelector('#bar').hasAttribute('data-md-collapsed') && document.querySelector('#bar').hasAttribute('data-md-scrolled')")
->assertScript("Math.round(document.querySelector('#bar [data-app-bar-row]').getBoundingClientRect().top) === 0") ->assertScript("Math.round(document.querySelector('#bar [data-md-app-bar-row]').getBoundingClientRect().top) === 0")
->assertScript("document.querySelector('#bar').getBoundingClientRect().bottom <= 65") ->assertScript("document.querySelector('#bar').getBoundingClientRect().bottom <= 65")
->assertScript("{$headline} === '1'"); ->assertScript("{$headline} === '1'");
$page->script('window.scrollTo(0, 0)'); $page->script('window.scrollTo(0, 0)');
$page->assertScript("! document.querySelector('#bar').hasAttribute('data-collapsed')"); $page->assertScript("! document.querySelector('#bar').hasAttribute('data-md-collapsed')");
}); });
it('moves focus along a toolbar with the arrow keys', function () { it('moves focus along a toolbar with the arrow keys', function () {
@@ -145,3 +145,104 @@ it('turns section tabs into a picker on a phone', function () {
->assertAttribute('[data-section-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page') ->assertAttribute('[data-section-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page')
->assertScript("! document.querySelector('[data-section-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')"); ->assertScript("! document.querySelector('[data-section-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
}); });
class AppBarOverflowProbe extends Component
{
public int $stars = 0;
public function star(): void
{
$this->stars++;
}
public function render(): string
{
return <<<'BLADE'
<div>
<p>stars: <span id="stars">{{ $stars }}</span></p>
<x-app-bar title="Shares" id="bar" :actions="[
['label' => 'Search', 'icon' => 'search'],
['label' => 'Share', 'icon' => 'share'],
['label' => 'Star', 'icon' => 'star', 'wire:click' => 'star'],
['label' => 'Archive', 'icon' => 'archive'],
['label' => 'Delete', 'icon' => 'delete'],
]" />
<div style="height: 200vh"></div>
</div>
BLADE;
}
}
function appBarOverflowProbe()
{
Livewire::component('app-bar-overflow-probe', AppBarOverflowProbe::class);
Route::middleware('web')->get('/app-bar-overflow-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:app-bar-overflow-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/app-bar-overflow-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('shows the app bar overflow at exactly 599 and 600px', function () {
$visible = "[...document.querySelectorAll('#bar [data-md-app-bar-action]')].filter((el) => getComputedStyle(el).display !== 'none').length";
appBarOverflowProbe()->resize(599, 800)
// Two icon buttons below 600px — the kept action and the overflow button.
->assertScript("{$visible} === 1")
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"]')).display !== 'none'")
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"medium\"]')).display === 'none'")
->resize(600, 800)
// Four from 600px — three kept actions and the overflow button.
->assertScript("{$visible} === 3")
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"]')).display === 'none'")
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"medium\"]')).display !== 'none'")
->assertNoJavaScriptErrors();
});
it('moves the keyboard through an app bar\'s overflow menu, and closes it once the width no longer shows it', function () {
$page = appBarOverflowProbe()->resize(599, 800);
$page->click('#bar [data-md-app-bar-overflow="compact"] button')
->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
->keys(':focus', 'ArrowDown')
->assertScript("document.activeElement.textContent.includes('Star')")
->keys(':focus', 'ArrowDown')
->assertScript("document.activeElement.textContent.includes('Archive')")
->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')");
// Reopen it, then resize across 600px: the compact menu's trigger is no longer shown, so the
// menu it opened closes rather than staying open anchored to nothing.
$page->click('#bar [data-md-app-bar-overflow="compact"] button')
->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
->resize(900, 800)
->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
->assertNoJavaScriptErrors();
});
it('runs a wire:click action from either the icon button or its overflow menu item', function () {
$page = appBarOverflowProbe()->resize(900, 800);
$page->click('#bar [data-md-app-bar-action] [aria-label="Star"]')
->assertSeeIn('#stars', '1');
$page->resize(599, 800)
->click('#bar [data-md-app-bar-overflow="compact"] button')
->click('[role="menuitem"]:has-text("Star")')
->assertSeeIn('#stars', '2');
});
+1 -1
View File
@@ -232,7 +232,7 @@ it('skips to the content', function () {
}); });
it('moves an app bar\'s content under a safe area an application sets', function () { it('moves an app bar\'s content under a safe area an application sets', function () {
$row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)"; $row = "Math.round(document.querySelector('[data-md-app-bar] [data-md-app-bar-row]').getBoundingClientRect().top)";
$page = navigationReady(visit('/material')); $page = navigationReady(visit('/material'));
$top = (int) $page->script($row); $top = (int) $page->script($row);
+54 -38
View File
@@ -1,5 +1,7 @@
<?php <?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('draws a small, sticky app bar with a heading, navigation and actions', function () { it('draws a small, sticky app bar with a heading, navigation and actions', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-app-bar title="Shares" subtitle="12 active"> <x-app-bar title="Shares" subtitle="12 active">
@@ -11,37 +13,37 @@ it('draws a small, sticky app bar with a heading, navigation and actions', funct
expect($html) expect($html)
->toContain('<header') ->toContain('<header')
->toContain('x-data="materialAppBar"') ->toContain('x-data="materialAppBar"')
->toContain('data-variant="small"') ->toContain('data-md-variant="small"')
->toContain('data-sticky') ->toContain('data-md-sticky')
->toContain('data-subtitled') ->toContain('data-md-subtitled')
->toContain('<h1 data-app-bar-title>Shares</h1>') ->toContain('<h1 data-md-app-bar-title>Shares</h1>')
->toContain('<p data-app-bar-subtitle>12 active</p>') ->toContain('<p data-md-app-bar-subtitle>12 active</p>')
->toContain('<div data-app-bar-leading><button>menu</button></div>') ->toContain('<div data-md-app-bar-leading><button>menu</button></div>')
->toContain('<div data-app-bar-trailing><button>search</button></div>'); ->toContain('<div data-md-app-bar-trailing><button>search</button></div>');
}); });
it('gives a medium or large bar a collapsed title in its row and the heading under it', function () { it('gives a medium or large bar a collapsed title in its row and the heading under it', function () {
$html = (string) $this->blade('<x-app-bar variant="large" title="Settings" heading="h2" :sticky="false" />'); $html = (string) $this->blade('<x-app-bar variant="large" title="Settings" heading="h2" :sticky="false" />');
expect($html) expect($html)
->toContain('data-variant="large"') ->toContain('data-md-variant="large"')
->not->toContain('data-sticky') ->not->toContain('data-md-sticky')
->toContain('<div data-app-bar-headline aria-hidden="true" >') ->toContain('<div data-md-app-bar-headline aria-hidden="true" >')
->toContain('<span data-app-bar-title>Settings</span>') ->toContain('<span data-md-app-bar-title>Settings</span>')
->toContain('<div data-app-bar-expanded>') ->toContain('<div data-md-app-bar-expanded>')
->toContain('<h2 data-app-bar-title>Settings</h2>'); ->toContain('<h2 data-md-app-bar-title>Settings</h2>');
}); });
it('puts a search bar in the row of a search app bar', function () { it('puts a search bar in the row of a search app bar', function () {
expect((string) $this->blade('<x-app-bar variant="search"><x-search placeholder="Search shares" /></x-app-bar>')) expect((string) $this->blade('<x-app-bar variant="search"><x-search placeholder="Search shares" /></x-app-bar>'))
->toContain('data-app-bar-search') ->toContain('data-md-app-bar-search')
->toContain('placeholder="Search shares"'); ->toContain('placeholder="Search shares"');
}); });
it('falls back to a small bar and an h1 for unknown values', function () { it('falls back to a small bar and an h1 for unknown values', function () {
expect((string) $this->blade('<x-app-bar variant="huge" heading="section" title="Shares" />')) expect((string) $this->blade('<x-app-bar variant="huge" heading="section" title="Shares" />'))
->toContain('data-variant="small"') ->toContain('data-md-variant="small"')
->toContain('<h1 data-app-bar-title>'); ->toContain('<h1 data-md-app-bar-title>');
}); });
it('keeps two trailing icon buttons below medium and four from it, the rest in an overflow menu', function () { it('keeps two trailing icon buttons below medium and four from it, the rest in an overflow menu', function () {
@@ -55,17 +57,17 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
]" /> ]" />
BLADE); BLADE);
preg_match('/data-app-bar-overflow="compact">(.*?)data-app-bar-overflow="medium">(.*)$/s', $html, $menus); preg_match('/data-md-app-bar-overflow="compact">(.*?)data-md-app-bar-overflow="medium">(.*)$/s', $html, $menus);
[, $compact, $medium] = $menus + [null, '', '']; [, $compact, $medium] = $menus + [null, '', ''];
expect($html) expect($html)
// M3's "up to four trailing icons" on a larger screen, the overflow button one of them: the // 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 // 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). // 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, '<span data-md-app-bar-action'))->toBe(3)
->and(substr_count($html, 'data-overflows-compact'))->toBe(2) ->and(substr_count($html, 'data-md-overflows-compact'))->toBe(2)
->and($html)->toMatch('/<span data-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/') ->and($html)->toMatch('/<span data-md-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"/') ->toMatch('/<span data-md-app-bar-action\s+data-md-overflows-compact\s*>\s*<a [^>]*href="\/shares\/1\/share"[^>]*aria-label="Share"/')
->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/') ->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/')
// Every icon button is named, tooltipped and reaches 48px. // Every icon button is named, tooltipped and reaches 48px.
->toMatch('/<button[^>]*data-md-size="sm"[^>]*aria-label="Search"/') ->toMatch('/<button[^>]*data-md-size="sm"[^>]*aria-label="Search"/')
@@ -103,26 +105,26 @@ it('adds an overflow menu only at the widths something overflows', function () {
$four = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(4)]); $four = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(4)]);
expect($two) expect($two)
->not->toContain('data-app-bar-overflow') ->not->toContain('data-md-app-bar-overflow')
->not->toContain('data-overflows-compact') ->not->toContain('data-md-overflows-compact')
->not->toContain('More options') ->not->toContain('More options')
->and(substr_count($two, '<span data-app-bar-action'))->toBe(2) ->and(substr_count($two, '<span data-md-app-bar-action'))->toBe(2)
->and($four) ->and($four)
->toContain('data-app-bar-overflow="compact"') ->toContain('data-md-app-bar-overflow="compact"')
->not->toContain('data-app-bar-overflow="medium"') ->not->toContain('data-md-app-bar-overflow="medium"')
->and(substr_count($four, '<span data-app-bar-action'))->toBe(4) ->and(substr_count($four, '<span data-md-app-bar-action'))->toBe(4)
->and(substr_count($four, 'data-overflows-compact'))->toBe(3) ->and(substr_count($four, 'data-md-overflows-compact'))->toBe(3)
// An empty list draws no trailing area; markup in the slot is drawn as written, never // An empty list draws no trailing area; markup in the slot is drawn as written, never
// overflowing. // overflowing.
->and((string) $this->blade('<x-app-bar title="Shares" :actions="[]" />')) ->and((string) $this->blade('<x-app-bar title="Shares" :actions="[]" />'))
->not->toContain('data-app-bar-trailing') ->not->toContain('data-md-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>')) ->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>') ->toContain('<div data-md-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
->not->toContain('data-app-bar-overflow'); ->not->toContain('data-md-app-bar-overflow');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css')) 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 < 600px\) \{\s+\[data-md-app-bar-action\]\[data-md-overflows-compact\],\s+\[data-md-app-bar-overflow=\'medium\'\] \{\s+display: none;/')
->toMatch('/@media \(width >= 37\.5rem\) \{\s+\[data-app-bar-overflow="compact"\] \{\s+display: none;/'); ->toMatch('/@media \(width >= 600px\) \{\s+\[data-md-app-bar-overflow=\'compact\'\] \{\s+display: none;/');
}); });
it('draws floating and docked toolbars', function () { it('draws floating and docked toolbars', function () {
@@ -172,14 +174,28 @@ it('sets a FAB at the end of a docked toolbar, resting flat on it', function ()
}); });
it('centres a headline on a three-column row and curves the search container', function () { it('centres a headline on a three-column row and curves the search container', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'); $css = ComponentStylesheet::read('app-bar');
expect($css) expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11). // Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
->not->toContain('inset-inline: 3.5rem;') ->not->toContain('inset-inline: 56px;')
->toMatch('/\[data-variant="center"\] \[data-app-bar-row\] \{\s+display: grid;\s+grid-template-columns: 1fr auto 1fr;/') ->and($css->declarations('[data-md-app-bar][data-md-variant=\'center\'] [data-md-app-bar-row]'))
->toBe(['display' => 'grid', 'grid-template-columns' => '1fr auto 1fr'])
// 312dp, then half of what is left (N-09). // 312dp, then half of what is left (N-09).
->toContain('max-width: calc(19.5rem + (100% - 19.5rem) / 2);'); ->and($css->declarations('[data-md-app-bar-search]'))
->toHaveKey('max-width', 'calc(312px + (100% - 312px) / 2)');
});
it('collapses a medium or large bar into its row at the breakpoint its title collapses to', function () {
$css = ComponentStylesheet::read('app-bar');
// Sticks at a negative top (the bar minus its measured height) so the row alone remains.
expect($css->declarations('[data-md-app-bar][data-md-sticky]:is([data-md-variant=\'medium\'], [data-md-variant=\'large\'])'))
->toBe(['top' => 'calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height)))'])
->and($css->declarations('[data-md-app-bar-action][data-md-overflows-compact], [data-md-app-bar-overflow=\'medium\']', ['@media (width < 600px)']))
->toBe(['display' => 'none'])
->and($css->declarations('[data-md-app-bar-overflow=\'compact\']', ['@media (width >= 600px)']))
->toBe(['display' => 'none']);
}); });
it('keeps a toolbar at the bottom clear of the navigation bar', function () { it('keeps a toolbar at the bottom clear of the navigation bar', function () {
@@ -0,0 +1,134 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The navigation components, rewritten without Tailwind (plan step 36, the last audit group): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), and each has a stylesheet in `material.components` that
* imports the stylesheets of the components its view renders, writes its values from the tokens
* and its breakpoints as px range queries, and is imported from the "Navigation" block of
* components.css.
*
* The dataset grows by one name per component commit, the same rule ContainmentStylesheetsTest.php
* and the earlier groups follow. `<x-tabs>` and `<x-tab>` share one stylesheet (tabs.css) and one
* commit, so `navigationViews()` maps the 'tabs' entry onto both blade views; every other entry is
* one view, one stylesheet, as usual.
*/
dataset('navigation components', [
'app-bar',
]);
/**
* The blade view(s) a dataset name's checks read. One file for every name but 'tabs', whose panel
* lives in a second view, tab.blade.php, sharing tabs.css with tabs.blade.php.
*
* @return list<string>
*/
function navigationViews(string $name): array
{
return $name === 'tabs' ? ['tabs', 'tab'] : [$name];
}
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
*/
function assertNavigationBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
}
}
}
}
/**
* Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints
* are px, at one of M3's four.
*/
function assertNavigationTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
assertNavigationBreakpointsInPx($css, $label);
}
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('navigation components');
it('imports the stylesheet of every component its view renders', function (string $name) {
$rendered = collect(navigationViews($name))
->flatMap(function (string $view): array {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php"), $tags);
return $tags[1];
})
->unique()
// A component still written for Tailwind has no stylesheet of its own to import yet; its
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
})->with('navigation components');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
foreach (navigationViews($name) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('navigation components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertNavigationTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('navigation components');
it('is imported from the navigation block of components.css', function (string $name) {
$components = File::get(__DIR__.'/../../../resources/css/components.css');
$block = substr($components, (int) strpos($components, '/* Navigation */'));
expect($block)->toContain("@import './components/{$name}.css';");
})->with('navigation components');
it('scopes every element-wide selector to a data-md hook', function (string $name) {
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
// `dialog` or `:root` rule would restyle the application's own pages.
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m');
})->with('navigation components');
+11 -11
View File
@@ -6,7 +6,7 @@ it('draws a content region with a body and no app bar when it has no title', fun
expect(layoutRoot($html))->toMatchArray(['<' => 'div', 'data-md-pane' => 'data-md-pane']) expect(layoutRoot($html))->toMatchArray(['<' => 'div', 'data-md-pane' => 'data-md-pane'])
->not->toHaveKeys(['data-md-width', 'data-md-hide-below', 'data-md-hide-from']) ->not->toHaveKeys(['data-md-width', 'data-md-hide-below', 'data-md-hide-from'])
->and($html)->toMatch('/<div data-md-pane-body>The page<\/div>/') ->and($html)->toMatch('/<div data-md-pane-body>The page<\/div>/')
->not->toContain('data-app-bar') ->not->toContain('data-md-app-bar')
->not->toContain('data-md-pane-navigation'); ->not->toContain('data-md-pane-navigation');
}); });
@@ -19,18 +19,18 @@ it('gives the pane its own app bar, a direct child spanning it, with the title,
BLADE); BLADE);
expect($html) expect($html)
->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-app-bar[^>]*data-md-pane-bar/s') ->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-md-app-bar[^>]*data-md-pane-bar/s')
->toContain('<h2 data-app-bar-title>Settings</h2>') ->toContain('<h2 data-md-app-bar-title>Settings</h2>')
->toContain('<p data-app-bar-subtitle>Your account</p>') ->toContain('<p data-md-app-bar-subtitle>Your account</p>')
->toMatch('/<div data-app-bar-trailing><span>ACTIONS<\/span><\/div>/') ->toMatch('/<div data-md-app-bar-trailing><span>ACTIONS<\/span><\/div>/')
->toContain('data-sticky') ->toContain('data-md-sticky')
->toMatch('/<\/header>\s*<div data-md-pane-body>\s*BODY\s*<\/div>/s') ->toMatch('/<\/header>\s*<div data-md-pane-body>\s*BODY\s*<\/div>/s')
->and((string) $this->blade('<x-pane title="Settings" :sticky="false" />'))->not->toContain('data-sticky'); ->and((string) $this->blade('<x-pane title="Settings" :sticky="false" />'))->not->toContain('data-md-sticky');
}); });
it('draws a bar for actions alone, and names the heading h1 by default', function () { it('draws a bar for actions alone, and names the heading h1 by default', function () {
expect((string) $this->blade('<x-pane><x-slot:actions><span>ACTIONS</span></x-slot:actions></x-pane>'))->toContain('data-md-pane-bar') expect((string) $this->blade('<x-pane><x-slot:actions><span>ACTIONS</span></x-slot:actions></x-pane>'))->toContain('data-md-pane-bar')
->and((string) $this->blade('<x-pane title="Inbox" />'))->toContain('<h1 data-app-bar-title>Inbox</h1>'); ->and((string) $this->blade('<x-pane title="Inbox" />'))->toContain('<h1 data-md-app-bar-title>Inbox</h1>');
}); });
it('puts the section navigation under the bar and above the body', function () { it('puts the section navigation under the bar and above the body', function () {
@@ -43,7 +43,7 @@ it('puts the section navigation under the bar and above the body', function () {
expect($html)->toMatch('/<\/header>\s*<div data-md-pane-navigation><nav>SECTIONS<\/nav><\/div>\s*<div data-md-pane-body>\s*BODY/s') expect($html)->toMatch('/<\/header>\s*<div data-md-pane-navigation><nav>SECTIONS<\/nav><\/div>\s*<div data-md-pane-body>\s*BODY/s')
// It is the pane's section navigation, not the bar's leading button. // It is the pane's section navigation, not the bar's leading button.
->not->toContain('data-app-bar-leading'); ->not->toContain('data-md-app-bar-leading');
}); });
it('leads the bar with a back link, a back action for a list-detail, or the leading slot', function () { it('leads the bar with a back link, a back action for a list-detail, or the leading slot', function () {
@@ -51,12 +51,12 @@ it('leads the bar with a back link, a back action for a list-detail, or the lead
$action = (string) $this->blade('<x-pane title="Message" back />'); $action = (string) $this->blade('<x-pane title="Message" back />');
$leading = (string) $this->blade('<x-pane title="Inbox" back><x-slot:leading><button>MENU</button></x-slot:leading></x-pane>'); $leading = (string) $this->blade('<x-pane title="Inbox" back><x-slot:leading><button>MENU</button></x-slot:leading></x-pane>');
expect($link)->toMatch('/data-app-bar-leading><span data-md-pane-back\s*>/') expect($link)->toMatch('/data-md-app-bar-leading><span data-md-pane-back\s*>/')
->toMatch('/<a[^>]*href="\/shares"[^>]*aria-label="Back"/s') ->toMatch('/<a[^>]*href="\/shares"[^>]*aria-label="Back"/s')
->not->toContain('data-md-list-detail-back') ->not->toContain('data-md-list-detail-back')
->and($action)->toMatch('/<span data-md-pane-back\s+data-md-list-detail-back\s*>/') ->and($action)->toMatch('/<span data-md-pane-back\s+data-md-list-detail-back\s*>/')
->toMatch('/<button[^>]*aria-label="Back"[^>]*x-on:click="back\(\)"/s') ->toMatch('/<button[^>]*aria-label="Back"[^>]*x-on:click="back\(\)"/s')
->and($leading)->toContain('<div data-app-bar-leading><button>MENU</button></div>') ->and($leading)->toContain('<div data-md-app-bar-leading><button>MENU</button></div>')
->not->toContain('data-md-pane-back'); ->not->toContain('data-md-pane-back');
}); });