Draw the toolbar without Tailwind

Plan step 36 (navigation group, second stream): <x-toolbar>'s class
lists move into resources/css/components/toolbar.css, keyed on
data-md-toolbar (data-md-variant/-vertical/-vibrant/-rounded/
-toolbar-place) and data-md-toolbar-group/-toolbar-fab. The docked
form with a FAB (N-02's --material-bottom-bar clearance), the vertical
24dp margin (N-12), the standard toolbar's primary icon buttons
(N-13), and the rounded large-screen form with dividers from 840px
were all already correct; only their hooks, units and layer needed to
change. The file no longer sits outside every layer: button.css and
fab.css now draw their own ink through data-md-* attributes rather
than a Tailwind utility, so a toolbar's more specific selector always
beats their single base color/background-color/box-shadow declaration
without needing to escape the cascade layers.

Sizes stay px (64px row, 40px divider); spacing goes through the
measurement tokens where one matches the value (4px leading/trailing
gap, 16/32px docked spread, 8px floating ends, 16/24px placed
margins).

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

Carry-over from the app-bar step: layout/pane.css now imports
app-bar.css and button.css for the pane's own top app bar and back
button, now that both are rewritten. That import crosses into
material.components for the first time from a layout file, so
tests/Feature/StylesheetsTest.php's two layout-shape checks now skip
non-`layout/` files reached through it — each already covered by its
own group's stylesheet test.

Added the owed browser tests (docs/plans/material-3-browser-tests.md):
a docked toolbar clear of the navigation bar below medium, and the
rounded form with its divider height from 840px, clear of the
window's edges.

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:27:26 +02:00
co-authored by Claude Sonnet 5
parent b7641a35c2
commit 9c134b2dc7
10 changed files with 322 additions and 217 deletions
+1
View File
@@ -70,3 +70,4 @@
/* Navigation */
@import './components/app-bar.css';
@import './components/toolbar.css';
+106 -96
View File
@@ -1,6 +1,12 @@
/*
* M3 Expressive's toolbars (DockedToolbarTokens, FloatingToolbarTokens, androidx Compose Material 3,
* Apache-2.0).
* `<x-toolbar>`: M3 Expressive's toolbars (DockedToolbarTokens.kt, FloatingToolbarTokens.kt,
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md
* § Toolbars).
*
* [data-md-toolbar] role="toolbar"; data-md-variant, data-md-vertical, data-md-vibrant,
* data-md-rounded, data-md-toolbar-place
* [data-md-toolbar-fab] a FAB docked at the end, flat on the bar (elevation 0)
* [data-md-toolbar-group] a floating toolbar and its own FAB, side by side (or stacked)
*
* docked a 64px bar across the bottom of the screen in surface-container, square, its
* controls spread out 4 to 32px apart between 16px ends; with a FAB, the controls
@@ -9,174 +15,178 @@
* 8px at its ends and 4px between controls; `vertical` stands it on end
*
* Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
* 24dp, which is M3's minimum for each.
* 24dp, which is M3's minimum for each (N-12).
*
* On web and large screens a docked toolbar "can be rounded", and dividers can organise its
* controls into groups (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars):
* `rounded` does the first from `expanded`, and a divider in the slot the second at any width.
* controls into groups: `rounded` does the first from `expanded` (840px), and a divider in the
* slot the second at any width.
*
* Unlayered on purpose: a toolbar recolours the icon buttons inside it — primary in a standard
* one, on-primary-container in a vibrant one — and they draw their ink as utilities, which a rule
* in any layer loses to.
* `<x-button>` and `<x-fab>` draw their own ink from `data-md-color`/`data-md-variant`, not a
* utility, so a toolbar's recolouring rules — vibrant's icon buttons, a standard toolbar's primary
* ones (N-13), a docked FAB's flattened shadow — sit in this file's own `material.components`
* layer like everything else: each only overrides the single `color`/`background-color`/
* `box-shadow` declaration button.css or fab.css puts on its own base selector, so a toolbar's
* more specific selector always wins without needing to sit outside the layer.
*/
[data-toolbar] {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-toolbar] {
display: flex;
align-items: center;
gap: 0.25rem;
}
gap: var(--md-sys-measurement-space50);
}
[data-toolbar][data-variant="docked"] {
[data-md-toolbar][data-md-variant='docked'] {
width: 100%;
min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
min-height: calc(64px + var(--material-safe-bottom, env(safe-area-inset-bottom)));
justify-content: center;
column-gap: clamp(0.25rem, 4vw, 2rem);
padding-inline: 1rem;
column-gap: clamp(var(--md-sys-measurement-space50), 4vw, var(--md-sys-measurement-space400));
padding-inline: var(--md-sys-measurement-space200);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
}
[data-toolbar][data-variant="floating"] {
[data-md-toolbar][data-md-variant='floating'] {
display: inline-flex;
height: 4rem;
padding-inline: 0.5rem;
height: 64px;
padding-inline: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
box-shadow: var(--md-sys-elevation-3);
}
}
[data-toolbar][data-variant="floating"][data-vertical] {
width: 4rem;
[data-md-toolbar][data-md-variant='floating'][data-md-vertical] {
width: 64px;
height: auto;
flex-direction: column;
padding-block: 0.5rem;
padding-block: var(--md-sys-measurement-space100);
padding-inline: 0;
}
}
[data-toolbar][data-vibrant] {
[data-md-toolbar][data-md-vibrant] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
}
[data-toolbar][data-vibrant] [data-md-icon-button]:not([aria-pressed="true"]) {
[data-md-toolbar][data-md-vibrant] [data-md-icon-button]:not([aria-pressed='true']) {
color: var(--md-sys-color-on-primary-container);
}
}
[data-toolbar][data-vibrant] [data-md-icon-button][aria-pressed="true"] {
[data-md-toolbar][data-md-vibrant] [data-md-icon-button][aria-pressed='true'] {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
}
}
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
ends "Standard button (On primary container)" — the row above. An icon button carries its own
ink as a utility, so the container's `color` never reaches it and this rule has to. */
[data-toolbar]:not([data-vibrant]) [data-md-icon-button]:not([aria-pressed="true"]) {
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
ends "Standard button (On primary container)" — the row above (N-13). */
[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([aria-pressed='true']) {
color: var(--md-sys-color-primary);
}
}
/* A docked toolbar with a FAB: the controls at the start, the FAB at the end, resting on the bar
at elevation 0 — M3 puts a FAB nested in another component there, and Compose's
FloatingActionButtonDefaults.bottomAppBarFabElevation() is 0 in every state. `<x-fab>` draws its
shadow with utilities, which is one more reason this file is unlayered. */
[data-toolbar][data-variant="docked"]:has(> [data-toolbar-fab]) {
/* A docked toolbar with a FAB: the controls at the start, the FAB at the end, resting on the
bar at elevation 0 — M3 puts a FAB nested in another component there, and Compose's
FloatingActionButtonDefaults.bottomAppBarFabElevation() is 0 in every state. */
[data-md-toolbar][data-md-variant='docked']:has(> [data-md-toolbar-fab]) {
justify-content: flex-start;
}
}
[data-toolbar-fab] {
[data-md-toolbar-fab] {
display: flex;
margin-inline-start: auto;
}
}
[data-toolbar-fab] [data-md-fab],
[data-toolbar-fab] [data-md-fab]:hover {
[data-md-toolbar-fab] [data-md-fab],
[data-md-toolbar-fab] [data-md-fab]:hover {
box-shadow: none;
}
}
/* Groups of controls, a divider between each two (`<x-divider vertical />`, or `<x-divider />` in a
vertical toolbar). The divider draws itself stretched across its row, which in a toolbar would
run it into the container's edges; here it stands as long as the 40px icon buttons it separates,
centred on them, as the large-screen docked toolbar in M3's guidelines draws it
(toolbars/guidelines § Adaptive design → Resizing). The divider is `self-stretch`, a utility,
which this unlayered rule beats. */
[data-toolbar]:not([data-vertical]) > [role="separator"][aria-orientation="vertical"] {
/* Groups of controls, a divider between each two (`<x-divider vertical />`, or `<x-divider />`
in a vertical toolbar). The divider draws itself stretched across its row, which in a
toolbar would run it into the container's edges; here it stands as long as the 40px icon
buttons it separates, centred on them, as the large-screen docked toolbar in M3's
guidelines draws it (toolbars/guidelines § Adaptive design → Resizing). */
[data-md-toolbar]:not([data-md-vertical]) > [role='separator'][aria-orientation='vertical'] {
align-self: center;
height: 2.5rem;
}
height: 40px;
}
[data-toolbar][data-vertical] > [role="separator"][aria-orientation="horizontal"] {
[data-md-toolbar][data-md-vertical] > [role='separator'][aria-orientation='horizontal'] {
align-self: center;
width: 2.5rem;
}
width: 40px;
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-toolbar-group] {
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-md-toolbar-group] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
gap: var(--md-sys-measurement-space100);
}
[data-toolbar-group][data-vertical] {
[data-md-toolbar-group][data-md-vertical] {
flex-direction: column;
}
}
/* Placed over the page: centred above the bottom edge, or centred against the end edge.
/* Placed over the page: centred above the bottom edge, or centred against the end edge.
*
* A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one
* <x-scaffold> publishes as --material-bottom-bar, exactly as the FAB and the snackbar do —
* `max()`, not a sum, because that height already swallows the bottom safe area; without a bar
* the safe area alone applies. A *docked* toolbar and a navigation bar must never be on screen
* together at all (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars); the
* offset is the backstop, not a licence. */
[data-toolbar-place="bottom"] {
* the safe area alone applies (N-02). A *docked* toolbar and a navigation bar must never be on
* screen together at all; the offset is the backstop, not a licence.
*/
[data-md-toolbar-place='bottom'] {
position: fixed;
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200));
left: 50%;
z-index: 30;
translate: -50% 0;
}
}
[data-toolbar-place="end"] {
[data-md-toolbar-place='end'] {
position: fixed;
top: 50%;
inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
inset-inline-end: calc(var(--md-sys-measurement-space200) + var(--material-safe-right, env(safe-area-inset-right)));
z-index: 30;
translate: 0 -50%;
}
}
/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
toolbars should have a minimum 24dp margin"
(docs/reference/m3/components-navigation-selection-inputs.md § Toolbars). */
[data-toolbar][data-vertical][data-toolbar-place="end"],
[data-toolbar-group][data-vertical][data-toolbar-place="end"] {
inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));
}
/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
toolbars should have a minimum 24dp margin" (N-12). */
[data-md-toolbar][data-md-vertical][data-md-toolbar-place='end'],
[data-md-toolbar-group][data-md-vertical][data-md-toolbar-place='end'] {
inset-inline-end: calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)));
}
[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] {
[data-md-toolbar][data-md-variant='docked'][data-md-toolbar-place='bottom'] {
inset-inline: 0;
bottom: var(--material-bottom-bar, 0px);
left: 0;
translate: none;
}
}
/* The large-screen docked toolbar: from `expanded` (840px) fully rounded, like the one M3 shows on
the web, and spanning what it is written in. At the bottom of the window it lifts off the edges —
16dp from each side, M3's minimum outside padding for a toolbar, and 16px above the bottom bar or
the safe area, as a floating toolbar sits — so the curve never meets the window's frame, and the
bottom safe area, which it no longer touches, stops padding it. Below `expanded` it is the square,
full-width docked toolbar M3 requires ("Avoid applying rounded corners to the container"). */
@media (width >= 52.5rem) {
[data-toolbar][data-variant="docked"][data-rounded] {
min-height: 4rem;
/* The large-screen docked toolbar: from `expanded` (840px) fully rounded, like the one M3 shows
on the web, and spanning what it is written in. At the bottom of the window it lifts off the
edges — 16dp from each side, M3's minimum outside padding for a toolbar, and 16px above the
bottom bar or the safe area, as a floating toolbar sits — so the curve never meets the
window's frame, and the bottom safe area, which it no longer touches, stops padding it.
Below `expanded` it is the square, full-width docked toolbar M3 requires ("Avoid applying
rounded corners to the container"). */
@media (width >= 840px) {
[data-md-toolbar][data-md-variant='docked'][data-md-rounded] {
min-height: 64px;
padding-bottom: 0;
border-radius: var(--md-sys-shape-corner-full);
}
[data-toolbar][data-variant="docked"][data-rounded][data-toolbar-place="bottom"] {
right: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);
left: calc(1rem + var(--material-safe-left, env(safe-area-inset-left)));
[data-md-toolbar][data-md-variant='docked'][data-md-rounded][data-md-toolbar-place='bottom'] {
right: calc(var(--md-sys-measurement-space200) + var(--material-safe-right, env(safe-area-inset-right)));
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200));
left: calc(var(--md-sys-measurement-space200) + var(--material-safe-left, env(safe-area-inset-left)));
width: auto;
}
}
}
+4 -1
View File
@@ -20,12 +20,15 @@
* fills the room it has, M3's flexible pane. The back button of `data-md-pane-back` points the
* way it goes, so it mirrors in a right-to-left document (§ Layout → Bidirectionality / RTL).
*
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css.
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css. Imports app-bar.css and
* button.css for the pane's own top app bar and its back button (plan step 36 carry-over).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './visibility.css';
@import '../components/app-bar.css';
@import '../components/button.css';
@layer material.layout {
[data-md-pane] {
-1
View File
@@ -20,4 +20,3 @@
/* Navigation — leaving in step 36 */
@import './components/tabs.css';
@import './components/navigation.css';
@import './components/toolbar.css';
+23 -16
View File
@@ -14,13 +14,13 @@
`rounded` gives a docked toolbar the form M3 lets it take on web and large screens: "the docked
toolbar can be rounded. Dividers can be used to organize large amounts of items", and it "can be
… placed in different parts of the page" (docs/reference/m3/components-navigation-selection-inputs.md
§ Toolbars Behaviour and guidelines; toolbars/guidelines § Adaptive design Resizing). From `expanded`
(840px) it is a fully rounded bar that spans what it is written in, and at `place="bottom"` it
lifts clear of the window's edges; below `expanded` it is the square, full-width docked toolbar,
as M3 requires there. Separate groups of controls with `<x-divider vertical />` between them (in
a vertical toolbar, `<x-divider />`): inside a toolbar it stands as tall as an icon button rather
than across the whole bar. The floating toolbar has no such form — it is fully rounded already,
and M3's large-screen advice for it is to show more controls, or two toolbars at opposite edges.
§ Toolbars Behaviour and guidelines). From `expanded` (840px) it is a fully rounded bar that
spans what it is written in, and at `place="bottom"` it lifts clear of the window's edges; below
`expanded` it is the square, full-width docked toolbar, as M3 requires there. Separate groups of
controls with `<x-divider vertical />` between them (in a vertical toolbar, `<x-divider />`):
inside a toolbar it stands as tall as an icon button rather than across the whole bar. The
floating toolbar has no such form — it is fully rounded already, and M3's large-screen advice
for it is to show more controls, or two toolbars at opposite edges.
A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to
show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual
@@ -29,7 +29,14 @@
Put `<x-button icon="…" tooltip="…" />` controls in the slot (`:selected` for toggles). It is a
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,
resources/css/components/toolbar.css). --}}
resources/css/components/toolbar.css).
The toolbar renders `data-md-toolbar` with `data-md-variant`, `data-md-vertical`,
`data-md-vibrant`, `data-md-rounded` and `data-md-toolbar-place`; a FAB beside a floating
toolbar sits in `data-md-toolbar-group` (with the toolbar itself), one docked in
`data-md-toolbar-fab`. Drawn by resources/css/components/toolbar.css, which the view renders
no other component into — a caller's own `<x-button>`, `<x-fab>` and `<x-divider>` bring
their own stylesheets. --}}
@props([
'variant' => 'floating',
@@ -50,26 +57,26 @@
@endphp
@if ($grouped)
<div data-toolbar-group @if ($vertical) data-vertical @endif @if ($place) data-toolbar-place="{{ $place }}" @endif>
<div data-md-toolbar-group @if ($vertical) data-md-vertical @endif @if ($place) data-md-toolbar-place="{{ $place }}" @endif>
@endif
<div
role="toolbar"
x-data="materialToolbar({{ $vertical ? 'true' : 'false' }})"
x-on:keydown="move($event)"
data-toolbar
data-variant="{{ $variant }}"
@if ($vertical) data-vertical aria-orientation="vertical" @endif
@if ($vibrant && $variant === 'floating') data-vibrant @endif
@if ($rounded) data-rounded @endif
@if ($place && ! $grouped) data-toolbar-place="{{ $place }}" @endif
data-md-toolbar
data-md-variant="{{ $variant }}"
@if ($vertical) data-md-vertical aria-orientation="vertical" @endif
@if ($vibrant && $variant === 'floating') data-md-vibrant @endif
@if ($rounded) data-md-rounded @endif
@if ($place && ! $grouped) data-md-toolbar-place="{{ $place }}" @endif
@if (filled($label)) aria-label="{{ $label }}" @endif
{{ $attributes }}
>
{{ $slot }}
@if ($docksFab)
<div data-toolbar-fab>{{ $fab }}</div>
<div data-md-toolbar-fab>{{ $fab }}</div>
@endif
</div>
+56
View File
@@ -246,3 +246,59 @@ it('runs a wire:click action from either the icon button or its overflow menu it
->click('[role="menuitem"]:has-text("Star")')
->assertSeeIn('#stars', '2');
});
function toolbarScaffoldProbe()
{
Route::middleware('web')->get('/toolbar-scaffold-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<x-scaffold :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/toolbar-scaffold-probe', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/toolbar-scaffold-probe'],
]">
<x-toolbar variant="docked" rounded place="bottom" label="Formatting" id="toolbar">
<x-button icon="format_bold" tooltip="Bold" />
<x-divider vertical />
<x-button icon="format_italic" tooltip="Italic" />
</x-toolbar>
<div style="height: 200vh"></div>
</x-scaffold>
@livewireScripts
</body>
</html>
BLADE));
return visit('/toolbar-scaffold-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('keeps a docked toolbar clear of the navigation bar below medium', function () {
toolbarScaffoldProbe()->resize(599, 800)
->assertScript("getComputedStyle(document.querySelector('[data-app-shell-bar]')).display !== 'none'")
// A docked toolbar at place="bottom" sits above --material-bottom-bar, never on top of it.
->assertScript("document.querySelector('#toolbar').getBoundingClientRect().bottom <= document.querySelector('[data-app-shell-bar]').getBoundingClientRect().top")
->assertNoJavaScriptErrors();
});
it('rounds a docked toolbar and its divider from 840px, clear of the window\'s edges', function () {
$page = toolbarScaffoldProbe()->resize(800, 800);
$page->assertScript("getComputedStyle(document.querySelector('#toolbar')).borderRadius === '0px'")
->assertScript("Math.round(document.querySelector('#toolbar').getBoundingClientRect().left) === 0")
->assertScript("Math.round(document.querySelector('#toolbar').getBoundingClientRect().right) === Math.round(window.innerWidth)");
$page->resize(1024, 800)
->assertScript("getComputedStyle(document.querySelector('#toolbar')).borderRadius !== '0px'")
->assertScript("document.querySelector('#toolbar').getBoundingClientRect().left >= 16")
->assertScript("window.innerWidth - document.querySelector('#toolbar').getBoundingClientRect().right >= 16")
->assertScript("window.innerHeight - document.querySelector('#toolbar').getBoundingClientRect().bottom >= 16")
->assertScript("getComputedStyle(document.querySelector('#toolbar [role=\"separator\"]')).height === '40px'")
->assertNoJavaScriptErrors();
});
+25 -20
View File
@@ -136,20 +136,20 @@ it('draws floating and docked toolbars', function () {
BLADE);
expect($html)
->toContain('<div data-toolbar-group data-vertical data-toolbar-place="end" >')
->toContain('<div data-md-toolbar-group data-md-vertical data-md-toolbar-place="end" >')
->toContain('role="toolbar"')
->toContain('materialToolbar(true)')
->toContain('data-variant="floating"')
->toContain('data-md-variant="floating"')
->toContain('aria-orientation="vertical"')
->toContain('data-vibrant')
->toContain('data-md-vibrant')
->toContain('aria-label="Formatting"')
->toContain('<button>+</button>')
->and((string) $this->blade('<x-toolbar variant="docked" vibrant vertical place="bottom"><button>B</button></x-toolbar>'))
->toContain('data-variant="docked"')
->not->toContain('data-vibrant')
->not->toContain('data-vertical')
->toContain('data-toolbar-place="bottom"')
->not->toContain('data-toolbar-group');
->toContain('data-md-variant="docked"')
->not->toContain('data-md-vibrant')
->not->toContain('data-md-vertical')
->toContain('data-md-toolbar-place="bottom"')
->not->toContain('data-md-toolbar-group');
});
it('sets a FAB at the end of a docked toolbar, resting flat on it', function () {
@@ -162,15 +162,17 @@ it('sets a FAB at the end of a docked toolbar, resting flat on it', function ()
expect($html)
// Inside the toolbar, after its controls, where the arrow keys reach it too.
->toMatch('/role="toolbar".*<button>Draw<\/button>\s*<div data-toolbar-fab><button>\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-toolbar-group')
->toMatch('/role="toolbar".*<button>Draw<\/button>\s*<div data-md-toolbar-fab><button>\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-md-toolbar-group')
->and((string) $this->blade('<x-toolbar variant="docked"><button>Draw</button></x-toolbar>'))
->not->toContain('data-toolbar-fab');
->not->toContain('data-md-toolbar-fab');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css'))
->toMatch('/\[data-toolbar-fab\] \{\s+display: flex;\s+margin-inline-start: auto;/')
$css = ComponentStylesheet::read('toolbar');
expect($css->declarations('[data-md-toolbar-fab]'))->toBe(['display' => 'flex', 'margin-inline-start' => 'auto'])
// A nested FAB rests at elevation 0.
->toMatch('/\[data-toolbar-fab\] \[data-md-fab\],\s+\[data-toolbar-fab\] \[data-md-fab\]:hover \{\s+box-shadow: none;/');
->and($css->declarations('[data-md-toolbar-fab] [data-md-fab], [data-md-toolbar-fab] [data-md-fab]:hover'))
->toBe(['box-shadow' => 'none']);
});
it('centres a headline on a three-column row and curves the search container', function () {
@@ -199,16 +201,19 @@ it('collapses a medium or large bar into its row at the breakpoint its title col
});
it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css');
$css = ComponentStylesheet::read('toolbar');
expect($css)
expect($css->declarations('[data-md-toolbar-place=\'bottom\']'))
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
->toContain('bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);')
->toContain('bottom: var(--material-bottom-bar, 0px);')
->toHaveKey('bottom', 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))')
->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-toolbar-place=\'bottom\']'))
->toHaveKey('bottom', 'var(--material-bottom-bar, 0px)')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
->toContain('inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));')
->and($css->declarations('[data-md-toolbar][data-md-vertical][data-md-toolbar-place=\'end\'], [data-md-toolbar-group][data-md-vertical][data-md-toolbar-place=\'end\']'))
->toBe(['inset-inline-end' => 'calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)))'])
// A standard toolbar's standard buttons are primary (N-13).
->toContain('[data-toolbar]:not([data-vibrant]) [data-md-icon-button]:not([aria-pressed="true"]) {');
->and($css->declarations('[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([aria-pressed=\'true\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
});
it('offers M3\'s three contrast levels, marking the one in force', function () {
@@ -19,6 +19,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
*/
dataset('navigation components', [
'app-bar',
'toolbar',
]);
/**
+26 -18
View File
@@ -1,5 +1,7 @@
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
// The toolbar's first tests live in AppBarTest.php, beside the app bar they were written with.
it('gives a docked toolbar the rounded large-screen form on request, and only a docked one', function () {
@@ -12,36 +14,42 @@ it('gives a docked toolbar the rounded large-screen form on request, and only a
BLADE);
expect($docked)
->toMatch('/role="toolbar"[^>]*data-variant="docked"\s+data-rounded/')
->toMatch('/role="toolbar"[^>]*data-md-variant="docked"\s+data-md-rounded/')
// The divider between two groups is the toolbar's child, where toolbar.css stands it up.
->toMatch('/role="toolbar".*aria-label="Undo".*<div\s+data-md-divider\s+data-md-orientation="vertical"\s+role="separator" aria-orientation="vertical"[^>]*>\s*<\/div>.*aria-label="Bold"/s')
->and((string) $this->blade('<x-toolbar rounded><x-button icon="undo" tooltip="Undo" /></x-toolbar>'))
->toContain('data-variant="floating"')
->not->toContain('data-rounded')
->toContain('data-md-variant="floating"')
->not->toContain('data-md-rounded')
->and((string) $this->blade('<x-toolbar variant="docked"><x-button icon="undo" tooltip="Undo" /></x-toolbar>'))
->not->toContain('data-rounded');
->not->toContain('data-md-rounded');
});
it('rounds a docked toolbar from expanded only, and lifts a placed one off the window\'s edges', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css');
$css = ComponentStylesheet::read('toolbar');
preg_match('/@media \(width >= 52\.5rem\) \{(.*)\}\s*$/s', $css, $expanded);
expect($expanded[1] ?? '')
expect($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-rounded]', ['@media (width >= 840px)']))
// "On web and large screens, the docked toolbar can be rounded" (toolbars/guidelines).
->toMatch('/\[data-toolbar\]\[data-variant="docked"\]\[data-rounded\] \{[^}]*border-radius: var\(--md-sys-shape-corner-full\);/')
->toMatch('/\[data-toolbar\]\[data-variant="docked"\]\[data-rounded\] \{[^}]*padding-bottom: 0;/')
->toBe(['min-height' => '64px', 'padding-bottom' => '0', 'border-radius' => 'var(--md-sys-shape-corner-full)'])
// 16dp from each side, M3's minimum outside padding; 16px above a bottom bar.
->toMatch('/\[data-rounded\]\[data-toolbar-place="bottom"\] \{[^}]*right: calc\(1rem \+ var\(--material-safe-right, env\(safe-area-inset-right\)\)\);/')
->toMatch('/\[data-rounded\]\[data-toolbar-place="bottom"\] \{[^}]*left: calc\(1rem \+ var\(--material-safe-left, env\(safe-area-inset-left\)\)\);/')
->toMatch('/\[data-rounded\]\[data-toolbar-place="bottom"\] \{[^}]*bottom: calc\(max\(var\(--material-bottom-bar, 0px\), var\(--material-safe-bottom, env\(safe-area-inset-bottom\)\)\) \+ 1rem\);/')
->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-rounded][data-md-toolbar-place=\'bottom\']', ['@media (width >= 840px)']))
->toBe([
'right' => 'calc(var(--md-sys-measurement-space200) + var(--material-safe-right, env(safe-area-inset-right)))',
'bottom' => 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))',
'left' => 'calc(var(--md-sys-measurement-space200) + var(--material-safe-left, env(safe-area-inset-left)))',
'width' => 'auto',
]);
preg_match('/@media \(width >= 840px\) \{(.*)\}\s*\}\s*$/s', $css->css, $expanded);
// Nothing outside the query rounds a docked toolbar: below expanded it stays square.
->and(str_replace($expanded[0] ?? '', '', $css))
->not->toContain('[data-rounded]');
expect(str_replace($expanded[0] ?? '', '', $css->css))->not->toContain('[data-md-rounded]');
});
it('stands a divider in a toolbar as tall as the buttons it separates', function () {
expect(file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css'))
->toMatch('/\[data-toolbar\]:not\(\[data-vertical\]\) > \[role="separator"\]\[aria-orientation="vertical"\] \{\s+align-self: center;\s+height: 2\.5rem;/')
->toMatch('/\[data-toolbar\]\[data-vertical\] > \[role="separator"\]\[aria-orientation="horizontal"\] \{\s+align-self: center;\s+width: 2\.5rem;/');
$css = ComponentStylesheet::read('toolbar');
expect($css->declarations('[data-md-toolbar]:not([data-md-vertical]) > [role=\'separator\'][aria-orientation=\'vertical\']'))
->toBe(['align-self' => 'center', 'height' => '40px'])
->and($css->declarations('[data-md-toolbar][data-md-vertical] > [role=\'separator\'][aria-orientation=\'horizontal\']'))
->toBe(['align-self' => 'center', 'width' => '40px']);
});
+15
View File
@@ -381,8 +381,16 @@ it('opens every layout stylesheet with a header, the layer statement and plain i
});
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
// file draws on (pane.css: app-bar.css and button.css, for its own top app bar and back
// button) — each already checked, in material.components, by its own group's stylesheet test.
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
if (! str_starts_with($name, 'layout/')) {
continue;
}
$css = File::get($file);
$layer = match ($name) {
'layout/visibility.css' => 'material.visibility',
@@ -405,8 +413,15 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
it('writes no Tailwind in the layout stylesheets, and media queries only at M3\'s breakpoints, in px', function () {
$queries = 0;
// Component stylesheets reached through a layout file (see the test above) are not layout
// stylesheets themselves, and keep their own group's rules (a hover query included).
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
if (! str_starts_with($name, 'layout/')) {
continue;
}
$css = stylesheetWithoutComments(File::get($file));
expect($css)