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 */ /* Navigation */
@import './components/app-bar.css'; @import './components/app-bar.css';
@import './components/toolbar.css';
+170 -160
View File
@@ -1,6 +1,12 @@
/* /*
* M3 Expressive's toolbars (DockedToolbarTokens, FloatingToolbarTokens, androidx Compose Material 3, * `<x-toolbar>`: M3 Expressive's toolbars (DockedToolbarTokens.kt, FloatingToolbarTokens.kt,
* Apache-2.0). * 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 * 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 * 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 * 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 * 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 * 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): * controls into groups: `rounded` does the first from `expanded` (840px), and a divider in the
* `rounded` does the first from `expanded`, and a divider in the slot the second at any width. * slot the second at any width.
* *
* Unlayered on purpose: a toolbar recolours the icon buttons inside it — primary in a standard * `<x-button>` and `<x-fab>` draw their own ink from `data-md-color`/`data-md-variant`, not a
* one, on-primary-container in a vibrant one — and they draw their ink as utilities, which a rule * utility, so a toolbar's recolouring rules — vibrant's icon buttons, a standard toolbar's primary
* in any layer loses to. * 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;
display: flex;
align-items: center;
gap: 0.25rem;
}
[data-toolbar][data-variant="docked"] { @layer material.components {
width: 100%; [data-md-toolbar] {
min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom))); display: flex;
justify-content: center; align-items: center;
column-gap: clamp(0.25rem, 4vw, 2rem); gap: var(--md-sys-measurement-space50);
padding-inline: 1rem; }
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='docked'] {
display: inline-flex; width: 100%;
height: 4rem; min-height: calc(64px + var(--material-safe-bottom, env(safe-area-inset-bottom)));
padding-inline: 0.5rem; justify-content: center;
border-radius: var(--md-sys-shape-corner-full); column-gap: clamp(var(--md-sys-measurement-space50), 4vw, var(--md-sys-measurement-space400));
background-color: var(--md-sys-color-surface-container); padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant); padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
box-shadow: var(--md-sys-elevation-3); background-color: var(--md-sys-color-surface-container);
} color: var(--md-sys-color-on-surface-variant);
}
[data-toolbar][data-variant="floating"][data-vertical] { [data-md-toolbar][data-md-variant='floating'] {
width: 4rem; display: inline-flex;
height: auto; height: 64px;
flex-direction: column; padding-inline: var(--md-sys-measurement-space100);
padding-block: 0.5rem;
padding-inline: 0;
}
[data-toolbar][data-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"]) {
color: var(--md-sys-color-on-primary-container);
}
[data-toolbar][data-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"]) {
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]) {
justify-content: flex-start;
}
[data-toolbar-fab] {
display: flex;
margin-inline-start: auto;
}
[data-toolbar-fab] [data-md-fab],
[data-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"] {
align-self: center;
height: 2.5rem;
}
[data-toolbar][data-vertical] > [role="separator"][aria-orientation="horizontal"] {
align-self: center;
width: 2.5rem;
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-toolbar-group] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
[data-toolbar-group][data-vertical] {
flex-direction: column;
}
/* 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"] {
position: fixed;
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);
left: 50%;
z-index: 30;
translate: -50% 0;
}
[data-toolbar-place="end"] {
position: fixed;
top: 50%;
inset-inline-end: calc(1rem + 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)));
}
[data-toolbar][data-variant="docked"][data-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;
padding-bottom: 0;
border-radius: var(--md-sys-shape-corner-full); 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="docked"][data-rounded][data-toolbar-place="bottom"] { [data-md-toolbar][data-md-variant='floating'][data-md-vertical] {
right: calc(1rem + var(--material-safe-right, env(safe-area-inset-right))); width: 64px;
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem); height: auto;
left: calc(1rem + var(--material-safe-left, env(safe-area-inset-left))); flex-direction: column;
width: auto; padding-block: var(--md-sys-measurement-space100);
padding-inline: 0;
}
[data-md-toolbar][data-md-vibrant] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-md-toolbar][data-md-vibrant] [data-md-icon-button]:not([aria-pressed='true']) {
color: var(--md-sys-color-on-primary-container);
}
[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 (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. */
[data-md-toolbar][data-md-variant='docked']:has(> [data-md-toolbar-fab]) {
justify-content: flex-start;
}
[data-md-toolbar-fab] {
display: flex;
margin-inline-start: auto;
}
[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). */
[data-md-toolbar]:not([data-md-vertical]) > [role='separator'][aria-orientation='vertical'] {
align-self: center;
height: 40px;
}
[data-md-toolbar][data-md-vertical] > [role='separator'][aria-orientation='horizontal'] {
align-self: center;
width: 40px;
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-md-toolbar-group] {
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space100);
}
[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.
*
* 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 (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))) + var(--md-sys-measurement-space200));
left: 50%;
z-index: 30;
translate: -50% 0;
}
[data-md-toolbar-place='end'] {
position: fixed;
top: 50%;
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" (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-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 >= 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-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 * 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). * 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; @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './visibility.css'; @import './visibility.css';
@import '../components/app-bar.css';
@import '../components/button.css';
@layer material.layout { @layer material.layout {
[data-md-pane] { [data-md-pane] {
-1
View File
@@ -20,4 +20,3 @@
/* Navigation — leaving in step 36 */ /* Navigation — leaving in step 36 */
@import './components/tabs.css'; @import './components/tabs.css';
@import './components/navigation.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 `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 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 … 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` § Toolbars Behaviour and guidelines). From `expanded` (840px) it is a fully rounded bar that
(840px) it is a fully rounded bar that spans what it is written in, and at `place="bottom"` it spans what it is written in, and at `place="bottom"` it lifts clear of the window's edges; below
lifts clear of the window's edges; below `expanded` it is the square, full-width docked toolbar, `expanded` it is the square, full-width docked toolbar, as M3 requires there. Separate groups of
as M3 requires there. Separate groups of controls with `<x-divider vertical />` between them (in controls with `<x-divider vertical />` between them (in a vertical toolbar, `<x-divider />`):
a vertical toolbar, `<x-divider />`): inside a toolbar it stands as tall as an icon button rather inside a toolbar it stands as tall as an icon button rather than across the whole bar. The
than across the whole bar. The floating toolbar has no such form — it is fully rounded already, floating toolbar has no such form — it is fully rounded already, and M3's large-screen advice
and M3's large-screen advice for it is to show more controls, or two toolbars at opposite edges. 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 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 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 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, `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([ @props([
'variant' => 'floating', 'variant' => 'floating',
@@ -50,26 +57,26 @@
@endphp @endphp
@if ($grouped) @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 @endif
<div <div
role="toolbar" role="toolbar"
x-data="materialToolbar({{ $vertical ? 'true' : 'false' }})" x-data="materialToolbar({{ $vertical ? 'true' : 'false' }})"
x-on:keydown="move($event)" x-on:keydown="move($event)"
data-toolbar data-md-toolbar
data-variant="{{ $variant }}" data-md-variant="{{ $variant }}"
@if ($vertical) data-vertical aria-orientation="vertical" @endif @if ($vertical) data-md-vertical aria-orientation="vertical" @endif
@if ($vibrant && $variant === 'floating') data-vibrant @endif @if ($vibrant && $variant === 'floating') data-md-vibrant @endif
@if ($rounded) data-rounded @endif @if ($rounded) data-md-rounded @endif
@if ($place && ! $grouped) data-toolbar-place="{{ $place }}" @endif @if ($place && ! $grouped) data-md-toolbar-place="{{ $place }}" @endif
@if (filled($label)) aria-label="{{ $label }}" @endif @if (filled($label)) aria-label="{{ $label }}" @endif
{{ $attributes }} {{ $attributes }}
> >
{{ $slot }} {{ $slot }}
@if ($docksFab) @if ($docksFab)
<div data-toolbar-fab>{{ $fab }}</div> <div data-md-toolbar-fab>{{ $fab }}</div>
@endif @endif
</div> </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")') ->click('[role="menuitem"]:has-text("Star")')
->assertSeeIn('#stars', '2'); ->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); BLADE);
expect($html) 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('role="toolbar"')
->toContain('materialToolbar(true)') ->toContain('materialToolbar(true)')
->toContain('data-variant="floating"') ->toContain('data-md-variant="floating"')
->toContain('aria-orientation="vertical"') ->toContain('aria-orientation="vertical"')
->toContain('data-vibrant') ->toContain('data-md-vibrant')
->toContain('aria-label="Formatting"') ->toContain('aria-label="Formatting"')
->toContain('<button>+</button>') ->toContain('<button>+</button>')
->and((string) $this->blade('<x-toolbar variant="docked" vibrant vertical place="bottom"><button>B</button></x-toolbar>')) ->and((string) $this->blade('<x-toolbar variant="docked" vibrant vertical place="bottom"><button>B</button></x-toolbar>'))
->toContain('data-variant="docked"') ->toContain('data-md-variant="docked"')
->not->toContain('data-vibrant') ->not->toContain('data-md-vibrant')
->not->toContain('data-vertical') ->not->toContain('data-md-vertical')
->toContain('data-toolbar-place="bottom"') ->toContain('data-md-toolbar-place="bottom"')
->not->toContain('data-toolbar-group'); ->not->toContain('data-md-toolbar-group');
}); });
it('sets a FAB at the end of a docked toolbar, resting flat on it', function () { 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) expect($html)
// Inside the toolbar, after its controls, where the arrow keys reach it too. // 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') ->toMatch('/role="toolbar".*<button>Draw<\/button>\s*<div data-md-toolbar-fab><button>\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-toolbar-group') ->not->toContain('data-md-toolbar-group')
->and((string) $this->blade('<x-toolbar variant="docked"><button>Draw</button></x-toolbar>')) ->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')) $css = ComponentStylesheet::read('toolbar');
->toMatch('/\[data-toolbar-fab\] \{\s+display: flex;\s+margin-inline-start: auto;/')
expect($css->declarations('[data-md-toolbar-fab]'))->toBe(['display' => 'flex', 'margin-inline-start' => 'auto'])
// A nested FAB rests at elevation 0. // 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 () { 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 () { 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). // 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);') ->toHaveKey('bottom', 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))')
->toContain('bottom: var(--material-bottom-bar, 0px);') ->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). // 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). // 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 () { 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', [ dataset('navigation components', [
'app-bar', 'app-bar',
'toolbar',
]); ]);
/** /**
+27 -19
View File
@@ -1,5 +1,7 @@
<?php <?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
// The toolbar's first tests live in AppBarTest.php, beside the app bar they were written with. // 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 () { 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); BLADE);
expect($docked) 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. // 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') ->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>')) ->and((string) $this->blade('<x-toolbar rounded><x-button icon="undo" tooltip="Undo" /></x-toolbar>'))
->toContain('data-variant="floating"') ->toContain('data-md-variant="floating"')
->not->toContain('data-rounded') ->not->toContain('data-md-rounded')
->and((string) $this->blade('<x-toolbar variant="docked"><x-button icon="undo" tooltip="Undo" /></x-toolbar>')) ->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 () { 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($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-rounded]', ['@media (width >= 840px)']))
expect($expanded[1] ?? '')
// "On web and large screens, the docked toolbar can be rounded" (toolbars/guidelines). // "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\);/') ->toBe(['min-height' => '64px', 'padding-bottom' => '0', 'border-radius' => 'var(--md-sys-shape-corner-full)'])
->toMatch('/\[data-toolbar\]\[data-variant="docked"\]\[data-rounded\] \{[^}]*padding-bottom: 0;/')
// 16dp from each side, M3's minimum outside padding; 16px above a bottom bar. // 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\)\)\);/') ->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-rounded][data-md-toolbar-place=\'bottom\']', ['@media (width >= 840px)']))
->toMatch('/\[data-rounded\]\[data-toolbar-place="bottom"\] \{[^}]*left: calc\(1rem \+ var\(--material-safe-left, env\(safe-area-inset-left\)\)\);/') ->toBe([
->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\);/') 'right' => 'calc(var(--md-sys-measurement-space200) + var(--material-safe-right, env(safe-area-inset-right)))',
// Nothing outside the query rounds a docked toolbar: below expanded it stays square. 'bottom' => 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))',
->and(str_replace($expanded[0] ?? '', '', $css)) 'left' => 'calc(var(--md-sys-measurement-space200) + var(--material-safe-left, env(safe-area-inset-left)))',
->not->toContain('[data-rounded]'); '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.
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 () { 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')) $css = ComponentStylesheet::read('toolbar');
->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;/'); 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 () { 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) { foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file); $name = stylesheetName($file);
if (! str_starts_with($name, 'layout/')) {
continue;
}
$css = File::get($file); $css = File::get($file);
$layer = match ($name) { $layer = match ($name) {
'layout/visibility.css' => 'material.visibility', '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 () { it('writes no Tailwind in the layout stylesheets, and media queries only at M3\'s breakpoints, in px', function () {
$queries = 0; $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) { foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file); $name = stylesheetName($file);
if (! str_starts_with($name, 'layout/')) {
continue;
}
$css = stylesheetWithoutComments(File::get($file)); $css = stylesheetWithoutComments(File::get($file));
expect($css) expect($css)