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:
co-authored by
Claude Sonnet 5
parent
b7f120af98
commit
b7641a35c2
@@ -69,3 +69,4 @@
|
||||
@import './components/error-page.css';
|
||||
|
||||
/* Navigation */
|
||||
@import './components/app-bar.css';
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
/*
|
||||
* M3 Expressive's top app bars (AppBarTokens, AppBarSmallTokens, AppBarMediumFlexibleTokens,
|
||||
* AppBarLargeFlexibleTokens, androidx Compose Material 3, Apache-2.0).
|
||||
* `<x-app-bar>`: M3 Expressive's top app bars (AppBarTokens.kt, AppBarSmallTokens.kt,
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* from it, by media query alone (the site's "trailing actions collapse into an overflow menu at
|
||||
* 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 {
|
||||
[data-app-bar] {
|
||||
--app-bar-row: 4rem;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@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);
|
||||
|
||||
z-index: 20;
|
||||
@@ -32,40 +52,42 @@
|
||||
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||
background-color: var(--md-sys-color-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"] {
|
||||
--app-bar-height: 7rem;
|
||||
[data-md-app-bar][data-md-variant='medium'] {
|
||||
--app-bar-height: 112px;
|
||||
}
|
||||
|
||||
[data-app-bar][data-variant="medium"][data-subtitled] {
|
||||
--app-bar-height: 8.5rem;
|
||||
[data-md-app-bar][data-md-variant='medium'][data-md-subtitled] {
|
||||
--app-bar-height: 136px;
|
||||
}
|
||||
|
||||
[data-app-bar][data-variant="large"] {
|
||||
--app-bar-height: 7.5rem;
|
||||
[data-md-app-bar][data-md-variant='large'] {
|
||||
--app-bar-height: 120px;
|
||||
}
|
||||
|
||||
[data-app-bar][data-variant="large"][data-subtitled] {
|
||||
--app-bar-height: 9.5rem;
|
||||
[data-md-app-bar][data-md-variant='large'][data-md-subtitled] {
|
||||
--app-bar-height: 152px;
|
||||
}
|
||||
|
||||
[data-app-bar][data-sticky] {
|
||||
[data-md-app-bar][data-md-sticky] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* `--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)));
|
||||
}
|
||||
|
||||
[data-app-bar][data-scrolled] {
|
||||
[data-md-app-bar][data-md-scrolled] {
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
}
|
||||
|
||||
[data-app-bar-row] {
|
||||
[data-md-app-bar-row] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -73,63 +95,62 @@
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: var(--app-bar-row);
|
||||
padding-inline: 0.25rem;
|
||||
padding-inline: var(--md-sys-measurement-space50);
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
[data-app-bar-leading],
|
||||
[data-app-bar-trailing] {
|
||||
[data-md-app-bar-leading],
|
||||
[data-md-app-bar-trailing] {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-app-bar-leading] {
|
||||
[data-md-app-bar-leading] {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
[data-app-bar-trailing] {
|
||||
[data-md-app-bar-trailing] {
|
||||
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] {
|
||||
from it, an overflow button counted among them. The wrappers stay out of the row's layout
|
||||
until a width hides them. */
|
||||
[data-md-app-bar-action],
|
||||
[data-md-app-bar-overflow] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (width < 37.5rem) {
|
||||
[data-app-bar-action][data-overflows-compact],
|
||||
[data-app-bar-overflow="medium"] {
|
||||
@media (width < 600px) {
|
||||
[data-md-app-bar-action][data-md-overflows-compact],
|
||||
[data-md-app-bar-overflow='medium'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 37.5rem) {
|
||||
[data-app-bar-overflow="compact"] {
|
||||
@media (width >= 600px) {
|
||||
[data-md-app-bar-overflow='compact'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-app-bar-headline] {
|
||||
[data-md-app-bar-headline] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-inline: 0.75rem;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
[data-app-bar-row]:has([data-app-bar-leading]) [data-app-bar-headline] {
|
||||
padding-inline-start: 0.25rem;
|
||||
[data-md-app-bar-row]:has([data-md-app-bar-leading]) [data-md-app-bar-headline] {
|
||||
padding-inline-start: var(--md-sys-measurement-space50);
|
||||
}
|
||||
|
||||
[data-app-bar-title] {
|
||||
[data-md-app-bar-title] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -137,7 +158,7 @@
|
||||
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
|
||||
}
|
||||
|
||||
[data-app-bar-subtitle] {
|
||||
[data-md-app-bar-subtitle] {
|
||||
overflow: hidden;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
text-overflow: ellipsis;
|
||||
@@ -148,91 +169,92 @@
|
||||
|
||||
/* 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
|
||||
buttons, which a fixed inset for one would have let a long title ellipsise underneath. */
|
||||
[data-app-bar][data-variant="center"] [data-app-bar-row] {
|
||||
buttons, which a fixed inset for one would have let a long title ellipsise underneath (N-11). */
|
||||
[data-md-app-bar][data-md-variant='center'] [data-md-app-bar-row] {
|
||||
display: grid;
|
||||
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;
|
||||
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;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
padding-inline: 0.75rem;
|
||||
padding-inline: 12px;
|
||||
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;
|
||||
justify-self: end;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
/* 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
|
||||
(docs/reference/m3/components-navigation-selection-inputs.md § Top app bar). The percentage
|
||||
is of the row rather than of that space, which CSS cannot name without measuring it; below
|
||||
312px the cap never binds, so a phone still gets the whole row. */
|
||||
[data-app-bar-search] {
|
||||
wide, and then grows into only half of what is left (N-09). The percentage is of the row
|
||||
rather than of that space, which CSS cannot name without measuring it; below 312px the cap
|
||||
never binds, so a phone still gets the whole row. */
|
||||
[data-md-app-bar-search] {
|
||||
min-width: 0;
|
||||
max-width: calc(19.5rem + (100% - 19.5rem) / 2);
|
||||
max-width: calc(312px + (100% - 312px) / 2);
|
||||
flex: 1 1 0%;
|
||||
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. */
|
||||
[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;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
[data-app-bar-expanded] {
|
||||
[data-md-app-bar-expanded] {
|
||||
display: flex;
|
||||
min-height: calc(var(--app-bar-height) - var(--app-bar-row));
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-inline: var(--md-sys-measurement-space200);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-app-bar][data-subtitled] [data-app-bar-expanded] {
|
||||
padding-bottom: 1rem;
|
||||
[data-md-app-bar][data-md-subtitled] [data-md-app-bar-expanded] {
|
||||
padding-bottom: var(--md-sys-measurement-space200);
|
||||
}
|
||||
|
||||
[data-app-bar][data-variant="large"][data-subtitled] [data-app-bar-expanded] {
|
||||
padding-bottom: 1.25rem;
|
||||
[data-md-app-bar][data-md-variant='large'][data-md-subtitled] [data-md-app-bar-expanded] {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
[data-app-bar-expanded] [data-app-bar-title] {
|
||||
[data-md-app-bar-expanded] [data-md-app-bar-title] {
|
||||
white-space: normal;
|
||||
font: var(--md-sys-typescale-headline-md);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
letter-spacing: var(--md-sys-typescale-title-md-tracking);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,5 @@
|
||||
|
||||
/* Navigation — leaving in step 36 */
|
||||
@import './components/tabs.css';
|
||||
@import './components/app-bar.css';
|
||||
@import './components/navigation.css';
|
||||
@import './components/toolbar.css';
|
||||
|
||||
@@ -16,20 +16,29 @@
|
||||
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.
|
||||
§ Top App Bar → Behaviour and guidelines). 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
|
||||
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([
|
||||
'title' => null,
|
||||
@@ -71,30 +80,30 @@
|
||||
|
||||
<header
|
||||
x-data="materialAppBar"
|
||||
x-bind:data-scrolled="scrolled ? '' : null"
|
||||
x-bind:data-collapsed="collapsed ? '' : null"
|
||||
x-bind:data-md-scrolled="scrolled ? '' : null"
|
||||
x-bind:data-md-collapsed="collapsed ? '' : null"
|
||||
x-bind:style="measured"
|
||||
data-app-bar
|
||||
data-variant="{{ $variant }}"
|
||||
@if ($sticky) data-sticky @endif
|
||||
@if (filled($subtitle)) data-subtitled @endif
|
||||
data-md-app-bar
|
||||
data-md-variant="{{ $variant }}"
|
||||
@if ($sticky) data-md-sticky @endif
|
||||
@if (filled($subtitle)) data-md-subtitled @endif
|
||||
{{ $attributes }}
|
||||
>
|
||||
<div data-app-bar-row>
|
||||
<div data-md-app-bar-row>
|
||||
@isset($navigation)
|
||||
<div data-app-bar-leading>{{ $navigation }}</div>
|
||||
<div data-md-app-bar-leading>{{ $navigation }}</div>
|
||||
@endisset
|
||||
|
||||
@if ($variant === 'search')
|
||||
<div data-app-bar-search>{{ $slot }}</div>
|
||||
<div data-md-app-bar-search>{{ $slot }}</div>
|
||||
@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)
|
||||
<span data-app-bar-title>{{ $title }}</span>
|
||||
<span data-md-app-bar-title>{{ $title }}</span>
|
||||
@else
|
||||
<{{ $heading }} data-app-bar-title>{{ $title }}</{{ $heading }}>
|
||||
<{{ $heading }} data-md-app-bar-title>{{ $title }}</{{ $heading }}>
|
||||
@if (filled($subtitle))
|
||||
<p data-app-bar-subtitle>{{ $subtitle }}</p>
|
||||
<p data-md-app-bar-subtitle>{{ $subtitle }}</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@@ -102,10 +111,10 @@
|
||||
|
||||
@if ($list !== null)
|
||||
@if ($count > 0)
|
||||
<div data-app-bar-trailing>
|
||||
<div data-md-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>
|
||||
<span data-md-app-bar-action @if ($index >= $shown['compact']) data-md-overflows-compact @endif>
|
||||
<x-livewire-material::button
|
||||
:icon="$action['icon']"
|
||||
:tooltip-bottom="$action['label']"
|
||||
@@ -122,7 +131,7 @@
|
||||
|
||||
@foreach ($shown as $width => $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-slot:trigger>
|
||||
<x-livewire-material::button icon="more_vert" :tooltip-bottom="__('More options')" />
|
||||
@@ -147,15 +156,15 @@
|
||||
</div>
|
||||
@endif
|
||||
@elseif (isset($actions))
|
||||
<div data-app-bar-trailing>{{ $actions }}</div>
|
||||
<div data-md-app-bar-trailing>{{ $actions }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($flexible && filled($title))
|
||||
<div data-app-bar-expanded>
|
||||
<{{ $heading }} data-app-bar-title>{{ $title }}</{{ $heading }}>
|
||||
<div data-md-app-bar-expanded>
|
||||
<{{ $heading }} data-md-app-bar-title>{{ $title }}</{{ $heading }}>
|
||||
@if (filled($subtitle))
|
||||
<p data-app-bar-subtitle>{{ $subtitle }}</p>
|
||||
<p data-md-app-bar-subtitle>{{ $subtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
+106
-5
@@ -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 () {
|
||||
$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()
|
||||
->assertScript("! document.querySelector('#bar').hasAttribute('data-collapsed')")
|
||||
->assertScript("! document.querySelector('#bar').hasAttribute('data-md-collapsed')")
|
||||
->assertScript("{$headline} === '0'");
|
||||
|
||||
$page->script('window.scrollTo(0, 600)');
|
||||
|
||||
$page->assertScript("document.querySelector('#bar').hasAttribute('data-collapsed') && document.querySelector('#bar').hasAttribute('data-scrolled')")
|
||||
->assertScript("Math.round(document.querySelector('#bar [data-app-bar-row]').getBoundingClientRect().top) === 0")
|
||||
$page->assertScript("document.querySelector('#bar').hasAttribute('data-md-collapsed') && document.querySelector('#bar').hasAttribute('data-md-scrolled')")
|
||||
->assertScript("Math.round(document.querySelector('#bar [data-md-app-bar-row]').getBoundingClientRect().top) === 0")
|
||||
->assertScript("document.querySelector('#bar').getBoundingClientRect().bottom <= 65")
|
||||
->assertScript("{$headline} === '1'");
|
||||
|
||||
$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 () {
|
||||
@@ -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')
|
||||
->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');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
$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'));
|
||||
$top = (int) $page->script($row);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('draws a small, sticky app bar with a heading, navigation and actions', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<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)
|
||||
->toContain('<header')
|
||||
->toContain('x-data="materialAppBar"')
|
||||
->toContain('data-variant="small"')
|
||||
->toContain('data-sticky')
|
||||
->toContain('data-subtitled')
|
||||
->toContain('<h1 data-app-bar-title>Shares</h1>')
|
||||
->toContain('<p data-app-bar-subtitle>12 active</p>')
|
||||
->toContain('<div data-app-bar-leading><button>menu</button></div>')
|
||||
->toContain('<div data-app-bar-trailing><button>search</button></div>');
|
||||
->toContain('data-md-variant="small"')
|
||||
->toContain('data-md-sticky')
|
||||
->toContain('data-md-subtitled')
|
||||
->toContain('<h1 data-md-app-bar-title>Shares</h1>')
|
||||
->toContain('<p data-md-app-bar-subtitle>12 active</p>')
|
||||
->toContain('<div data-md-app-bar-leading><button>menu</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 () {
|
||||
$html = (string) $this->blade('<x-app-bar variant="large" title="Settings" heading="h2" :sticky="false" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-variant="large"')
|
||||
->not->toContain('data-sticky')
|
||||
->toContain('<div data-app-bar-headline aria-hidden="true" >')
|
||||
->toContain('<span data-app-bar-title>Settings</span>')
|
||||
->toContain('<div data-app-bar-expanded>')
|
||||
->toContain('<h2 data-app-bar-title>Settings</h2>');
|
||||
->toContain('data-md-variant="large"')
|
||||
->not->toContain('data-md-sticky')
|
||||
->toContain('<div data-md-app-bar-headline aria-hidden="true" >')
|
||||
->toContain('<span data-md-app-bar-title>Settings</span>')
|
||||
->toContain('<div data-md-app-bar-expanded>')
|
||||
->toContain('<h2 data-md-app-bar-title>Settings</h2>');
|
||||
});
|
||||
|
||||
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>'))
|
||||
->toContain('data-app-bar-search')
|
||||
->toContain('data-md-app-bar-search')
|
||||
->toContain('placeholder="Search shares"');
|
||||
});
|
||||
|
||||
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" />'))
|
||||
->toContain('data-variant="small"')
|
||||
->toContain('<h1 data-app-bar-title>');
|
||||
->toContain('data-md-variant="small"')
|
||||
->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 () {
|
||||
@@ -55,17 +57,17 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
|
||||
]" />
|
||||
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, '', ''];
|
||||
|
||||
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"/')
|
||||
->and(substr_count($html, '<span data-md-app-bar-action'))->toBe(3)
|
||||
->and(substr_count($html, 'data-md-overflows-compact'))->toBe(2)
|
||||
->and($html)->toMatch('/<span data-md-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
|
||||
->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)"/')
|
||||
// Every icon button is named, tooltipped and reaches 48px.
|
||||
->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)]);
|
||||
|
||||
expect($two)
|
||||
->not->toContain('data-app-bar-overflow')
|
||||
->not->toContain('data-overflows-compact')
|
||||
->not->toContain('data-md-app-bar-overflow')
|
||||
->not->toContain('data-md-overflows-compact')
|
||||
->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)
|
||||
->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)
|
||||
->toContain('data-md-app-bar-overflow="compact"')
|
||||
->not->toContain('data-md-app-bar-overflow="medium"')
|
||||
->and(substr_count($four, '<span data-md-app-bar-action'))->toBe(4)
|
||||
->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
|
||||
// overflowing.
|
||||
->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>'))
|
||||
->toContain('<div data-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
|
||||
->not->toContain('data-app-bar-overflow');
|
||||
->toContain('<div data-md-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
|
||||
->not->toContain('data-md-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;/');
|
||||
->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 >= 600px\) \{\s+\[data-md-app-bar-overflow=\'compact\'\] \{\s+display: none;/');
|
||||
});
|
||||
|
||||
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 () {
|
||||
$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->toContain('inset-inline: 3.5rem;')
|
||||
->toMatch('/\[data-variant="center"\] \[data-app-bar-row\] \{\s+display: grid;\s+grid-template-columns: 1fr auto 1fr;/')
|
||||
->not->toContain('inset-inline: 56px;')
|
||||
->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).
|
||||
->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 () {
|
||||
|
||||
@@ -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');
|
||||
@@ -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'])
|
||||
->not->toHaveKeys(['data-md-width', 'data-md-hide-below', 'data-md-hide-from'])
|
||||
->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');
|
||||
});
|
||||
|
||||
@@ -19,18 +19,18 @@ it('gives the pane its own app bar, a direct child spanning it, with the title,
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-app-bar[^>]*data-md-pane-bar/s')
|
||||
->toContain('<h2 data-app-bar-title>Settings</h2>')
|
||||
->toContain('<p data-app-bar-subtitle>Your account</p>')
|
||||
->toMatch('/<div data-app-bar-trailing><span>ACTIONS<\/span><\/div>/')
|
||||
->toContain('data-sticky')
|
||||
->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-md-app-bar[^>]*data-md-pane-bar/s')
|
||||
->toContain('<h2 data-md-app-bar-title>Settings</h2>')
|
||||
->toContain('<p data-md-app-bar-subtitle>Your account</p>')
|
||||
->toMatch('/<div data-md-app-bar-trailing><span>ACTIONS<\/span><\/div>/')
|
||||
->toContain('data-md-sticky')
|
||||
->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 () {
|
||||
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 () {
|
||||
@@ -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')
|
||||
// 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 () {
|
||||
@@ -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 />');
|
||||
$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')
|
||||
->not->toContain('data-md-list-detail-back')
|
||||
->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')
|
||||
->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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user