Draw the navigation rail without Tailwind

Plan step 36 (navigation group, second batch): <x-navigation-rail>'s,
<x-navigation-rail-item>'s and <x-navigation-rail-section>'s class
lists move into navigation-rail.css, navigation-rail-item.css and
navigation-rail-section.css, keyed on data-md-navigation-rail (the
mode, data-md-width, data-md-align, data-md-hide-when-collapsed,
data-md-divider, data-md-fill, data-md-open), data-md-navigation-rail-
item (data-md-active) and data-md-navigation-rail-section. Every
Tailwind wrapper class in the view — the menu row's centring padding,
the FAB row, the two swapped menu glyphs, the brand's visibility —
becomes a hook the stylesheet draws instead; the menu button itself
renders the shared md-state-layer/md-focus-ring/md-touch-target
classes (N-01's pattern) since it draws its own layer on itself, not a
child. `<x-icon>` and `<x-badge>` take size and floating props instead
of size/position classes; a small data-md-navigation-icon hook (the
shared navigation-item.css) replaces the ad hoc "relative inline-flex"
wrapper a floating badge anchors to.

`rail-collapsed` (a Tailwind @custom-variant, forbidden in Phase F) is
reproduced as plain selectors, branch for branch: the three width-
independent conditions (a fixed collapsed mode; a collapsible rail the
visitor collapsed and not open; a modal rail not open) merge into one
:where() group, provably the same match set as three separate rules
since :where(A, B, C) on an element is true exactly when :where(A) or
:where(B) or :where(C) is; the four width-gated conditions stay
separate media blocks, since CSS cannot merge different `@media`
queries. Every rem length becomes px, since these are dp-based M3
tokens, not a text measure (unlike <x-pane>'s rem widths). The FAB
overrides for a rail's header — elevation 0 (N-03), morphing into an
extended FAB instead of swapping two by display (N-23) — move from
unlayered into this file's own material.components, like toolbar.css's
FAB override: fab.css's `[data-md-fab]` is one attribute, so a doubled
selector here always outranks it without needing to sit outside the
layer.

navigation.js: the arriving-indicator stylesheet and every code comment
follow the new hooks; resources/css/components/navigation.css is
deleted (nothing imports it any more) and its line in tailwind.css with
it. Behaviour is unchanged except one thing Tailwind's `rail-collapsed:`
variant could do that plain CSS in this shape cannot: it is gone for
consuming applications too, since the definition lived only in the file
this commit removes. The development skill's guidance for it is
rewritten to point at navigation-rail.css's own selectors instead of
teaching a Tailwind variant that no longer exists.

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-15 00:09:06 +02:00
co-authored by Claude Sonnet 5
parent 63d421ccb6
commit 25549374ec
10 changed files with 1961 additions and 120 deletions
+3
View File
@@ -75,3 +75,6 @@
@import './components/navigation-item.css'; @import './components/navigation-item.css';
@import './components/navigation-bar.css'; @import './components/navigation-bar.css';
@import './components/navigation-bar-item.css'; @import './components/navigation-bar-item.css';
@import './components/navigation-rail.css';
@import './components/navigation-rail-item.css';
@import './components/navigation-rail-section.css';
@@ -0,0 +1,645 @@
/*
* `<x-navigation-rail-item>`: one destination in an `<x-navigation-rail>`, drawn in whichever
* shape the rail has (NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
* NavigationRailBaselineItemTokens.kt, NavigationRailColorTokens.kt, androidx Compose Material 3,
* Apache-2.0).
*
* [data-md-navigation-rail-item] data-md-active; the link or button itself
* [data-md-navigation-indicator] the 56×32 indicator, collapsed only
* [data-md-navigation-icon] the floating badge's anchor (navigation-item.css)
* [data-md-navigation-badge-icon] the small/large badge, collapsed only
* [data-md-navigation-label]
* [data-md-navigation-badge-end] the large badge at the label's end, expanded only
*
* Expanded, a 56px full-width pill: the icon, the label-large label beside it (up to two lines,
* collapsed) and a count at its end. Collapsed, the icon sits in a 56×32 `CornerFull` indicator
* over a label-medium label, with the count on the icon instead — badges move from the icon
* (collapsed) to the label end (expanded), M3's stated rule.
*
* The item's own state layer is split in two: while expanded the layer paints the whole item (the
* 56px pill *is* the indicator); while collapsed it paints only the 56×32 box. A CSS variant
* selector cannot follow a pseudo-element, so `--navigation-item-layer`/`--navigation-indicator-layer`
* hand `--navigation-layer` (navigation-item.css) on through variables, and each rule below
* outweighs navigation-item.css's shared one with a doubled `[data-md-navigation-rail-item]`
* attribute, the same specificity trick `navigation-rail.css` uses for the header's FAB.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './badge.css';
@import './navigation-item.css';
@layer material.components {
[data-md-navigation-rail-item] {
--navigation-layer: 0;
--navigation-item-layer: var(--navigation-layer);
--navigation-indicator-layer: 0;
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
height: 56px;
margin-inline: 20px;
padding-inline: 16px;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
}
[data-md-navigation-rail-item][data-md-navigation-rail-item]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-item-layer);
}
[data-md-navigation-rail-item][data-md-navigation-rail-item] [data-md-navigation-indicator]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-indicator-layer);
}
[data-md-navigation-rail-item][data-md-active] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-navigation-rail-item] [data-md-navigation-label] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-md-navigation-rail-item]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* The large badge at the label's end, expanded only — collapsed, the badge moves onto the
icon instead (`[data-md-navigation-badge-icon]`, `display: contents` there). */
[data-md-navigation-badge-end] {
display: flex;
flex-shrink: 0;
}
[data-md-navigation-badge-icon] {
display: none;
}
/* ---- Collapsed: icon over label, the 56×32 indicator, badge back on the icon. ---- */
[data-md-navigation-rail-item] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
}
[data-md-navigation-rail-item] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
}
[data-md-navigation-rail-item] [data-md-navigation-label] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
}
[data-md-navigation-rail-item][data-md-active] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
background-image: none;
color: var(--md-sys-color-secondary);
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
}
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
width: 56px;
height: 32px;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
width: 56px;
height: 32px;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
width: 56px;
height: 32px;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
width: 56px;
height: 32px;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
width: 56px;
height: 32px;
}
}
}
[data-md-navigation-rail-item][data-md-active] [data-md-navigation-indicator] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
}
[data-md-navigation-rail-item]:focus-visible {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
outline: none;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
outline: none;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
outline: none;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: none;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: none;
}
}
}
[data-md-navigation-rail-item]:focus-visible [data-md-navigation-indicator] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
}
[data-md-navigation-badge-end],
[data-md-navigation-badge-icon] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
display: none;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
display: none;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
display: none;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
}
[data-md-navigation-badge-icon] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
display: contents;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
display: contents;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
display: contents;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: contents;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: contents;
}
}
}
}
@@ -0,0 +1,114 @@
/*
* `<x-navigation-rail-section>`: a group of `<x-navigation-rail-item>`s under a heading, in an
* `<x-navigation-rail>`. M3 draws a section header only while the rail is expanded; collapsed, a
* little space sets the group apart instead, and the heading keeps naming the group for a screen
* reader either way (`role="group"`/`aria-labelledby`, rendered by the view).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-navigation-rail-section] {
display: flex;
flex-direction: column;
}
[data-md-navigation-rail-section]:not(:first-child) {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
margin-top: 12px;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
margin-top: 12px;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
margin-top: 12px;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
margin-top: 12px;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
margin-top: 12px;
}
}
}
[data-md-navigation-rail-heading] {
display: flex;
align-items: center;
min-height: 48px;
padding-inline: 36px 20px;
overflow: hidden;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
text-overflow: ellipsis;
}
[data-md-navigation-rail-heading] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
display: none;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
display: none;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
display: none;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
}
}
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -3,8 +3,9 @@
* *
* `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in * `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in
* localStorage. <x-theme-script> has already applied it before the first paint as * localStorage. <x-theme-script> has already applied it before the first paint as
* <html data-rail="expanded|collapsed">, which is what the stylesheet keys on (the * <html data-rail="expanded|collapsed">, which is what navigation-rail.css keys on (every branch
* `rail-collapsed:` variant); the store starts from that attribute and writes it back. * of "collapsed" reads it, or `data-rail-auto` where no choice has been made); the store starts
* from that attribute and writes it back.
* `$store.rail.auto` is true while nothing is stored — the value is `rail.default`, not a choice — * `$store.rail.auto` is true while nothing is stored — the value is `rail.default`, not a choice —
* and the adaptive rail then takes its window size class's default instead: collapsed in the * and the adaptive rail then takes its window size class's default instead: collapsed in the
* expanded class (8401199), expanded from `large` (1200), as M3 asks. The first `set()` drops it. * expanded class (8401199), expanded from `large` (1200), as M3 asks. The first `set()` drops it.
@@ -25,13 +26,13 @@ import { from, upTo } from './breakpoints.js'
* new page's indicator is new markup, so the only way to animate it is a starting style — and * new page's indicator is new markup, so the only way to animate it is a starting style — and
* only while a navigation swaps the page in, or every full load would animate it too. The sheet * only while a navigation swaps the page in, or every full load would animate it too. The sheet
* is adopted as the navigation starts and dropped two frames after it ends; the transition itself * is adopted as the navigation starts and dropped two frames after it ends; the transition itself
* is resources/css/components/navigation.css. * is navigation-item.css.
*/ */
const arriving = new CSSStyleSheet() const arriving = new CSSStyleSheet()
arriving.replaceSync(`@starting-style { arriving.replaceSync(`@starting-style {
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active], :is([data-md-navigation-bar-item], [data-md-navigation-rail-item])[data-md-active],
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active] :is([data-navigation-indicator], [data-navigation-pill]) { :is([data-md-navigation-bar-item], [data-md-navigation-rail-item])[data-md-active] :is([data-md-navigation-indicator], [data-md-navigation-pill]) {
background-size: 0% 100%; background-size: 0% 100%;
} }
}`) }`)
@@ -135,7 +136,7 @@ document.addEventListener('alpine:init', () => {
// From `large` (1200px) it starts expanded rather than collapsed, until someone // From `large` (1200px) it starts expanded rather than collapsed, until someone
// chooses otherwise; below that the expanded class starts it collapsed. Both mirror // chooses otherwise; below that the expanded class starts it collapsed. Both mirror
// the `rail-collapsed:` variant in resources/css/components/navigation.css. // navigation-rail.css's own branches for those two bands.
this.watch(from('large'), (matches) => (this.roomy = matches)) this.watch(from('large'), (matches) => (this.roomy = matches))
}, },
@@ -179,7 +180,7 @@ document.addEventListener('alpine:init', () => {
* Whether the rail has left the layout altogether — `hide-when-collapsed`, once the * Whether the rail has left the layout altogether — `hide-when-collapsed`, once the
* visitor collapses it. Not in the two bands where it is the window size class and not the * visitor collapses it. Not in the two bands where it is the window size class and not the
* visitor that collapses a rail: M3's "collapsed rail may not hide". The same two numbers * visitor that collapses a rail: M3's "collapsed rail may not hide". The same two numbers
* are in resources/css/components/navigation.css. * are in navigation-rail.css.
*/ */
get away() { get away() {
if (!hideWhenCollapsed || this.cramped || this.standing) { if (!hideWhenCollapsed || this.cramped || this.standing) {
@@ -43,32 +43,32 @@
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null, 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : 'button', 'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null, 'aria-current' => $active ? 'page' : null,
'data-active' => $active ? true : null, 'data-md-active' => $active ? true : null,
], fn ($value): bool => $value !== null)); ], fn ($value): bool => $value !== null));
@endphp @endphp
<{{ $tag }} data-navigation-rail-item {{ $attributes }}> <{{ $tag }} data-md-navigation-rail-item {{ $attributes }}>
<span data-navigation-indicator> <span data-md-navigation-indicator>
<span class="relative inline-flex"> <span data-md-navigation-icon>
@if ($icon) @if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" /> <x-livewire-material::icon :name="$icon" :filled="$active" />
@endif @endif
@if ($dot) @if ($dot)
<x-livewire-material::badge floating /> <x-livewire-material::badge floating />
@elseif ($count) @elseif ($count)
<span class="hidden rail-collapsed:contents"><x-livewire-material::badge :value="$badge" max="999" floating /></span> <span data-md-navigation-badge-icon><x-livewire-material::badge :value="$badge" max="999" floating /></span>
@endif @endif
</span> </span>
</span> </span>
<span data-navigation-label>{{ $label ?? $slot }}</span> <span data-md-navigation-label>{{ $label ?? $slot }}</span>
@if ($count) @if ($count)
<span class="flex shrink-0 rail-collapsed:hidden"><x-livewire-material::badge :value="$badge" max="999" /></span> <span data-md-navigation-badge-end><x-livewire-material::badge :value="$badge" max="999" /></span>
@endif @endif
@if ($spoken !== null) @if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span> <span class="md-visually-hidden">, {{ $spoken }}</span>
@endif @endif
</{{ $tag }}> </{{ $tag }}>
@@ -17,8 +17,8 @@
$headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10); $headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10);
@endphp @endphp
<div role="group" aria-labelledby="{{ $headingId }}" data-navigation-rail-section {{ $attributes }}> <div role="group" aria-labelledby="{{ $headingId }}" data-md-navigation-rail-section {{ $attributes }}>
<p id="{{ $headingId }}" data-navigation-rail-heading>{{ $label }}</p> <p id="{{ $headingId }}" data-md-navigation-rail-heading>{{ $label }}</p>
{{ $slot }} {{ $slot }}
</div> </div>
@@ -45,18 +45,18 @@
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by `footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
the scroller's edge. the scroller's edge.
Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies Anything else inside can take both shapes by matching the rail's own state directly, the same
while the rail is drawn collapsed for whatever reason: selectors resources/css/components/navigation-rail.css uses for every branch of "collapsed"
`<span class="rail-collapsed:hidden">…only while expanded…</span>` `data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode
`<span class="hidden rail-collapsed:inline-flex">…only while collapsed…</span>`. collapses in; that file is the one place the numbers need to stay right, rather than a
Nothing that shows while collapsed may be wider than 96px. Tailwind variant repeating them. Nothing that shows while collapsed may be wider than 96px.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
between M3's 220 and 360dp) or the word `narrow`, M3's other *collapsed* width between M3's 220 and 360dp) or the word `narrow`, M3's other *collapsed* width
(NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the (NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the
items are their icons alone because no label fits under a 56px indicator at that width; the items are their icons alone because no label fits under a 56px indicator at that width; the
labels stay in the accessibility tree, since they are what name the destinations, and a labels stay in the accessibility tree, since they are what name the destinations, and a
narrow rail expands to the default 16rem; `align` is `top` (the default) or `center`, which narrow rail expands to the default 256px; `align` is `top` (the default) or `center`, which
puts the destinations at the rail's vertical centre M3 prefers that on a tablet, for reach puts the destinations at the rail's vertical centre M3 prefers that on a tablet, for reach
while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3 while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3
asks; more destinations than fit go back to the top rather than out of reach above the asks; more destinations than fit go back to the top rather than out of reach above the
@@ -82,12 +82,12 @@
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3 https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
surface (surface-container and elevation 2 with a large inner corner when modal), 44px above surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
the header and 40px under it, 4px between collapsed items. The styles are the header and 40px under it, 4px between collapsed items. The styles are
resources/css/components/navigation.css; the behaviour resources/js/navigation.js. --}} resources/css/components/navigation-rail.css; the behaviour resources/js/navigation.js. --}}
@props([ @props([
'mode' => 'collapsible', 'mode' => 'collapsible',
'label' => null, 'label' => null,
'width' => '16rem', 'width' => '256px',
'align' => 'top', 'align' => 'top',
'hideWhenCollapsed' => false, 'hideWhenCollapsed' => false,
'menu' => null, 'menu' => null,
@@ -105,29 +105,29 @@
$menu ??= $interactive; $menu ??= $interactive;
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true); $collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
$narrow = $width === 'narrow'; $narrow = $width === 'narrow';
$width = $narrow ? '16rem' : $width; $width = $narrow ? '256px' : $width;
$centred = $align === 'center'; $centred = $align === 'center';
@endphp @endphp
<div <div
data-navigation-rail="{{ $mode }}" data-md-navigation-rail="{{ $mode }}"
@if ($narrow) data-width="narrow" @endif @if ($narrow) data-md-width="narrow" @endif
@if ($centred) data-align="center" @endif @if ($centred) data-md-align="center" @endif
@if ($hideWhenCollapsed) data-hide-when-collapsed @endif @if ($hideWhenCollapsed) data-md-hide-when-collapsed @endif
@if ($divider) data-divider @endif @if ($divider) data-md-divider @endif
@unless ($fill) data-fill="false" @endunless @unless ($fill) data-md-fill="false" @endunless
@if ($interactive) @if ($interactive)
x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})" x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})"
x-bind:data-open="open" x-bind:data-md-open="open"
@endif @endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }} {{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
> >
@if ($canOpen) @if ($canOpen)
<div data-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div> <div data-md-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
@endif @endif
<nav <nav
data-navigation-rail-panel data-md-navigation-rail-panel
aria-label="{{ $label ?? __('Main') }}" aria-label="{{ $label ?? __('Main') }}"
@if ($canOpen) @if ($canOpen)
x-trap.inert.noscroll="open" x-trap.inert.noscroll="open"
@@ -135,46 +135,43 @@
@endif @endif
> >
@if ($menu || isset($brand) || isset($header)) @if ($menu || isset($brand) || isset($header))
<div data-navigation-rail-header> <div data-md-navigation-rail-header>
@if ($menu || isset($brand)) @if ($menu || isset($brand))
{{-- The menu button is centred on the collapsed rail: 28px either side of a <div data-md-navigation-rail-menu-row>
40px button in 96, 20px in a narrow 80. --}}
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'rail-collapsed:ps-5' => $menu && $narrow, 'ps-5' => ! $menu])>
@if ($menu) @if ($menu)
<button <button
type="button" type="button"
data-navigation-rail-menu data-md-navigation-rail-menu
aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}" aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}"
aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}" aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}"
x-on:click="menu()" x-on:click="menu()"
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))" x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
x-bind:aria-expanded="expanded.toString()" x-bind:aria-expanded="expanded.toString()"
class="state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant" class="md-state-layer md-focus-ring md-touch-target"
> >
<span class="contents rail-collapsed:hidden"><x-livewire-material::icon name="menu_open" class="size-6" /></span> <span data-md-navigation-rail-menu-collapse-icon><x-livewire-material::icon name="menu_open" /></span>
<span class="hidden rail-collapsed:contents"><x-livewire-material::icon name="menu" class="size-6" /></span> <span data-md-navigation-rail-menu-expand-icon><x-livewire-material::icon name="menu" /></span>
</button> </button>
@endif @endif
@isset($brand) @isset($brand)
<div class="min-w-0 flex-1 rail-collapsed:hidden">{{ $brand }}</div> <div data-md-navigation-rail-brand>{{ $brand }}</div>
@endisset @endisset
</div> </div>
@endif @endif
@isset($header) @isset($header)
{{-- And so is a 56px FAB: 20px either side in 96, 12px in a narrow 80. --}} <div data-md-navigation-rail-fab-row {{ $header->attributes }}>{{ $header }}</div>
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5', 'rail-collapsed:px-3' => $narrow]) }}>{{ $header }}</div>
@endisset @endisset
</div> </div>
@endif @endif
<div data-navigation-rail-destinations> <div data-md-navigation-rail-destinations>
{{ $slot }} {{ $slot }}
</div> </div>
@isset($footer) @isset($footer)
<div data-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div> <div data-md-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
@endisset @endisset
</nav> </nav>
</div> </div>
+126 -67
View File
@@ -1,8 +1,10 @@
<?php <?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('draws each mode, falling back to collapsible', function (string $mode, string $expected) { it('draws each mode, falling back to collapsible', function (string $mode, string $expected) {
expect((string) $this->blade('<x-navigation-rail :mode="$mode"><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>', ['mode' => $mode])) expect((string) $this->blade('<x-navigation-rail :mode="$mode"><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>', ['mode' => $mode]))
->toContain("data-navigation-rail=\"{$expected}\"") ->toContain("data-md-navigation-rail=\"{$expected}\"")
->toContain('<nav') ->toContain('<nav')
->toContain('aria-label="Main"'); ->toContain('aria-label="Main"');
})->with([ })->with([
@@ -19,26 +21,26 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
expect($collapsible) expect($collapsible)
->toContain('x-data="materialNavigationRail(\'collapsible\', false)"') ->toContain('x-data="materialNavigationRail(\'collapsible\', false)"')
->toContain('data-navigation-rail-menu') ->toContain('data-md-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"') ->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"') ->toContain('aria-expanded="true"')
// The 40px menu button reaches M3's 48px target through the shared utility (N-01). // The 40px menu button reaches M3's 48px target through the shared classes (N-01).
->toContain('touch-target') ->toContain('class="md-state-layer md-focus-ring md-touch-target"')
->not->toContain('data-navigation-rail-scrim') ->not->toContain('data-md-navigation-rail-scrim')
->not->toContain('x-trap') ->not->toContain('x-trap')
->and((string) $this->blade('<x-navigation-rail mode="modal" />')) ->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
->toContain('aria-label="Expand navigation"') ->toContain('aria-label="Expand navigation"')
->toContain('data-navigation-rail-scrim') ->toContain('data-md-navigation-rail-scrim')
->toContain('x-trap.inert.noscroll="open"') ->toContain('x-trap.inert.noscroll="open"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />')) ->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-navigation-rail-menu') ->not->toContain('data-md-navigation-rail-menu')
->not->toContain('x-data') ->not->toContain('x-data')
->and((string) $this->blade('<x-navigation-rail mode="collapsible" :menu="false" />'))->not->toContain('data-navigation-rail-menu'); ->and((string) $this->blade('<x-navigation-rail mode="collapsible" :menu="false" />'))->not->toContain('data-md-navigation-rail-menu');
}); });
it('keeps the header and footer out of the scrolling destinations', function () { it('keeps the header and footer out of the scrolling destinations', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-navigation-rail mode="expanded" width="20rem"> <x-navigation-rail mode="expanded" width="320px">
<x-slot:brand><span>Brand</span></x-slot:brand> <x-slot:brand><span>Brand</span></x-slot:brand>
<x-slot:header><button>FAB</button></x-slot:header> <x-slot:header><button>FAB</button></x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /> <x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" />
@@ -47,117 +49,163 @@ it('keeps the header and footer out of the scrolling destinations', function ()
BLADE); BLADE);
expect($html) expect($html)
->toContain('--navigation-rail-width: 20rem') ->toContain('--navigation-rail-width: 320px')
->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s'); ->toMatch('/data-md-navigation-rail-header.*Brand.*FAB.*data-md-navigation-rail-destinations.*Inbox.*data-md-navigation-rail-footer.*Account/s');
}); });
it('takes M3\'s optional divider and turns the container fill off', function () { it('takes M3\'s optional divider and turns the container fill off', function () {
expect((string) $this->blade('<x-navigation-rail mode="expanded" divider :fill="false" />')) expect((string) $this->blade('<x-navigation-rail mode="expanded" divider :fill="false" />'))
->toContain('data-divider') ->toContain('data-md-divider')
->toContain('data-fill="false"') ->toContain('data-md-fill="false"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />')) ->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-divider') ->not->toContain('data-md-divider')
->not->toContain('data-fill'); ->not->toContain('data-md-fill');
$css = ComponentStylesheet::read('navigation-rail');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22). // Neither reaches a rail open over a scrim, which is a surface over the page (N-22).
->toContain('[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {') expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open]) > [data-md-navigation-rail-panel]'))
->toContain("[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {") ->toBe(['border-inline-end' => '1px solid var(--md-sys-color-outline-variant)'])
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open]) > [data-md-navigation-rail-panel]'))
->toBe(['background-color' => 'transparent'])
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24). // A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: var\\(--navigation-rail-collapsed-width\\);/"); ->and($css->declarations('[data-md-navigation-rail=\'collapsible\']', ['@media (width < 600px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
}); });
it('takes M3\'s narrow collapsed width, icons alone but still named', function () { it('takes M3\'s narrow collapsed width, icons alone but still named', function () {
$narrow = (string) $this->blade('<x-navigation-rail width="narrow"><x-slot:header><button>FAB</button></x-slot:header><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>'); $narrow = (string) $this->blade('<x-navigation-rail width="narrow"><x-slot:header><button>FAB</button></x-slot:header><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>');
expect($narrow) expect($narrow)
->toContain('data-width="narrow"') ->toContain('data-md-width="narrow"')
// A narrow rail expands to the same 16rem; `width` names one or the other, never both. // A narrow rail expands to the same 256px; `width` names one or the other, never both.
->toContain('--navigation-rail-width: 16rem') ->toContain('--navigation-rail-width: 256px')
// 40px menu button and 56px FAB centred in 80px rather than in 96. ->toContain('data-md-navigation-rail-menu-row')
->toContain('rail-collapsed:ps-5') ->toContain('data-md-navigation-rail-fab-row')
->toContain('rail-collapsed:px-3')
// The label is still rendered: it is what names the destination. // The label is still rendered: it is what names the destination.
->toContain('<span data-navigation-label>Inbox</span>') ->toContain('<span data-md-navigation-label>Inbox</span>')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" width="20rem" />')) ->and((string) $this->blade('<x-navigation-rail mode="collapsed" width="320px" />'))
->not->toContain('data-width') ->not->toContain('data-md-width')
->toContain('--navigation-rail-width: 20rem'); ->toContain('--navigation-rail-width: 320px');
$css = ComponentStylesheet::read('navigation-rail');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against the default 96dp. // NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against the default 96dp.
->toMatch("/\\[data-navigation-rail\\]\\[data-width='narrow'\\] \\{\\s+--navigation-rail-collapsed-width: 5rem;/") expect($css->declarations('[data-md-navigation-rail][data-md-width=\'narrow\']'))
->toMatch('/--navigation-rail-collapsed-width: 6rem;/') ->toBe(['--navigation-rail-collapsed-width' => '80px'])
// Out of the drawing, not out of the page. ->and($css->declarations('[data-md-navigation-rail]'))
->toContain("[data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {"); ->toHaveKey('--navigation-rail-collapsed-width', '96px')
// Out of the drawing, not out of the page: clipped only while collapsed.
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toHaveKey('clip-path', 'inset(50%)');
}); });
it('centres the destinations on request, leaving the header and footer at their ends', function () { it('centres the destinations on request, leaving the header and footer at their ends', function () {
expect((string) $this->blade('<x-navigation-rail mode="collapsed" align="center" />')) expect((string) $this->blade('<x-navigation-rail mode="collapsed" align="center" />'))
->toContain('data-align="center"') ->toContain('data-md-align="center"')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" />'))->not->toContain('data-align') ->and((string) $this->blade('<x-navigation-rail mode="collapsed" />'))->not->toContain('data-md-align')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" align="middle" />'))->not->toContain('data-align'); ->and((string) $this->blade('<x-navigation-rail mode="collapsed" align="middle" />'))->not->toContain('data-md-align');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// Only the destinations move, and `safe` gives the top back when there are too many. // Only the destinations move, and `safe` gives the top back when there are too many.
->toMatch("/\\[data-navigation-rail\\]\\[data-align='center'\\] \\[data-navigation-rail-destinations\\] \\{\\s+justify-content: safe center;/"); expect(ComponentStylesheet::read('navigation-rail')->declarations("[data-md-navigation-rail][data-md-align='center'] [data-md-navigation-rail-destinations]"))
->toBe(['justify-content' => 'safe center']);
}); });
it('hides a collapsible or adaptive rail entirely when told to, and only those', function () { it('hides a collapsible or adaptive rail entirely when told to, and only those', function () {
$hiding = (string) $this->blade('<x-navigation-rail hide-when-collapsed />'); $hiding = (string) $this->blade('<x-navigation-rail hide-when-collapsed />');
expect($hiding) expect($hiding)
->toContain('data-hide-when-collapsed') ->toContain('data-md-hide-when-collapsed')
->toContain("x-data=\"materialNavigationRail('collapsible', true)\"") ->toContain("x-data=\"materialNavigationRail('collapsible', true)\"")
// It needs the scrim and the focus trap a modal rail has: it comes back over the page. // It needs the scrim and the focus trap a modal rail has: it comes back over the page.
->toContain('data-navigation-rail-scrim') ->toContain('data-md-navigation-rail-scrim')
->toContain('x-trap.inert.noscroll="open"') ->toContain('x-trap.inert.noscroll="open"')
->and((string) $this->blade('<x-navigation-rail mode="adaptive" hide-when-collapsed />')) ->and((string) $this->blade('<x-navigation-rail mode="adaptive" hide-when-collapsed />'))
->toContain("x-data=\"materialNavigationRail('adaptive', true)\"") ->toContain("x-data=\"materialNavigationRail('adaptive', true)\"")
// A modal rail is already over the page; the two fixed modes mean what they say. // A modal rail is already over the page; the two fixed modes mean what they say.
->and((string) $this->blade('<x-navigation-rail mode="modal" hide-when-collapsed />')) ->and((string) $this->blade('<x-navigation-rail mode="modal" hide-when-collapsed />'))
->not->toContain('data-hide-when-collapsed') ->not->toContain('data-md-hide-when-collapsed')
->toContain("materialNavigationRail('modal', false)") ->toContain("materialNavigationRail('modal', false)")
->and((string) $this->blade('<x-navigation-rail mode="expanded" hide-when-collapsed />')) ->and((string) $this->blade('<x-navigation-rail mode="expanded" hide-when-collapsed />'))
->not->toContain('data-hide-when-collapsed') ->not->toContain('data-md-hide-when-collapsed')
->and((string) $this->blade('<x-navigation-rail />'))->not->toContain('data-hide-when-collapsed'); ->and((string) $this->blade('<x-navigation-rail />'))->not->toContain('data-md-hide-when-collapsed');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) $css = ComponentStylesheet::read('navigation-rail');
->toMatch('/\[data-navigation-rail\]\[data-hide-when-collapsed\] \{\s+@variant rail-collapsed \{\s+width: 0;/')
expect($css->declarations("[data-md-navigation-rail][data-md-hide-when-collapsed]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toBe(['width' => '0'])
// The two bands where the window, not the visitor, collapses a rail keep theirs. // The two bands where the window, not the visitor, collapses a rail keep theirs.
->toMatch("/@media \\(width < 37\\.5rem\\) \\{\\s+\\[data-navigation-rail='collapsible'\\]\\[data-hide-when-collapsed\\]/") ->and($css->declarations("[data-md-navigation-rail='collapsible'][data-md-hide-when-collapsed]", ['@media (width < 600px)']))
->toMatch("/@media \\(37\\.5rem <= width < 52\\.5rem\\) \\{\\s+\\[data-navigation-rail='adaptive'\\]\\[data-hide-when-collapsed\\]/") ->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
// Every collapsed branch stops while the rail is open, or what comes back would be narrow. ->and($css->declarations("[data-md-navigation-rail='adaptive'][data-md-hide-when-collapsed]", ['@media (600px <= width < 840px)']))
->and(substr_count(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'), ':not([data-open])')) ->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
->toBeGreaterThanOrEqual(12); });
/**
* The Tailwind-era `rail-collapsed:` custom variant had seven branches for "this rail is drawn
* collapsed"; navigation-rail.css merges the three that carry no media query into one `:where()`
* (provably the same match set: an element matches `X:where(A, B, C)` exactly when it matches
* `X:where(A)` or `X:where(B)` or `X:where(C)`) and keeps the four media-gated ones separate, since
* CSS cannot merge different `@media` queries into one rule. This pins all five physical branches
* — and so every one of the seven original conditions — for the rail's own width, the property
* every branch sets.
*/
it('reproduces every branch of the old rail-collapsed variant for the rail\'s own width', function () {
$css = ComponentStylesheet::read('navigation-rail');
// Branches 1-3: a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed and not
// open; a `modal` rail not open.
expect($css->declarations("[data-md-navigation-rail]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
// Branch 4: below `medium` (600px), a `collapsible` rail regardless of choice.
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *)", ['@media (width < 600px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
// Branch 5: below `expanded` (840px), an `adaptive` rail not open.
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *)", ['@media (width < 840px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
// Branch 6: at `expanded` (840-1199), an `adaptive` rail not open, chosen collapsed or no
// choice yet (`data-rail-auto`).
->and($css->declarations("[data-md-navigation-rail]:where( :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (840px <= width < 1200px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
// Branch 7: from `large` (1200px), an `adaptive` rail not open, only when chosen collapsed —
// M3 starts it expanded there by default.
->and($css->declarations("[data-md-navigation-rail]:where( [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (width >= 1200px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
}); });
it('flattens a FAB nested in the rail header and morphs its label', function () { it('flattens a FAB nested in the rail header and morphs its label', function () {
// Both rules hang off `data-md-fab` on <x-fab>'s root, and both are unlayered, because what they $css = ComponentStylesheet::read('navigation-rail');
// beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities.
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) // A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03). Now layered, like
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03). // toolbar.css's own docked-FAB override: fab.css's `[data-md-fab]` is one attribute, so the
->toContain('[data-navigation-rail-header] [data-md-fab],') // doubled selector here (two) always wins without needing to sit outside the layer.
->toContain('[data-navigation-rail-header] [data-md-fab]:hover {') expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-fab], [data-md-navigation-rail-header] [data-md-fab][data-md-fab]:hover'))
->toBe(['box-shadow' => 'none'])
// One FAB whose label springs shut, not two swapped by display (N-23). // One FAB whose label springs shut, not two swapped by display (N-23).
->toContain('[data-navigation-rail-header] [data-md-fab] > span {') ->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span'))
->toMatch('/@variant rail-collapsed \{\s+min-width: 0;\s+aspect-ratio: 1;\s+gap: 0;/'); ->toHaveKey('max-width', '256px');
expect($css->declarations("[data-md-navigation-rail-header] [data-md-fab] > span:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toBe(['max-width' => '0', 'opacity' => '0'])
->and($css->declarations("[data-md-navigation-rail-header] [data-md-fab][data-md-extended]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toBe(['min-inline-size' => '0', 'aspect-ratio' => '1', 'gap' => '0']);
}); });
it('draws a destination in both shapes, with its count on the icon and at the end', function () { it('draws a destination in both shapes, with its count on the icon and at the end', function () {
$html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />'); $html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
expect($html) expect($html)
->toContain('<a data-navigation-rail-item') ->toContain('<a data-md-navigation-rail-item')
->toContain('wire:navigate') ->toContain('wire:navigate')
->toContain('aria-current="page"') ->toContain('aria-current="page"')
->toContain('data-navigation-indicator') ->toContain('data-md-navigation-indicator')
->toContain('<span data-navigation-label>Inbox</span>') ->toContain('<span data-md-navigation-label>Inbox</span>')
->toContain('hidden rail-collapsed:contents') ->toContain('data-md-navigation-badge-icon')
->toContain('rail-collapsed:hidden') ->toContain('data-md-navigation-badge-end')
->toContain('<span class="sr-only">, 12</span>') ->toContain('<span class="md-visually-hidden">, 12</span>')
->and(substr_count($html, '>12</span>'))->toBe(2) ->and(substr_count($html, '>12</span>'))->toBe(2)
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate') ->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" />'))->toContain('<button data-navigation-rail-item'); ->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" />'))->toContain('<button data-md-navigation-rail-item');
}); });
it('groups destinations under a heading that names the group', function () { it('groups destinations under a heading that names the group', function () {
@@ -167,5 +215,16 @@ it('groups destinations under a heading that names the group', function () {
expect($html)->toContain('role="group"') expect($html)->toContain('role="group"')
->and($labelledBy[1] ?? null)->not->toBeNull() ->and($labelledBy[1] ?? null)->not->toBeNull()
->and($html)->toContain("<p id=\"{$labelledBy[1]}\" data-navigation-rail-heading>Labels</p>"); ->and($html)->toContain("<p id=\"{$labelledBy[1]}\" data-md-navigation-rail-heading>Labels</p>");
});
it('draws the item\'s own state layer split between the item and its indicator', function () {
$item = ComponentStylesheet::read('navigation-rail-item');
expect($item->declarations('[data-md-navigation-rail-item]'))
->toHaveKeys(['--navigation-item-layer', '--navigation-indicator-layer'])
->and($item->declarations('[data-md-navigation-rail-item][data-md-navigation-rail-item]::before'))
->toBe(['background-color' => 'var(--md-sys-color-on-secondary-container)', 'opacity' => 'var(--navigation-item-layer)'])
->and($item->declarations('[data-md-navigation-rail-item][data-md-navigation-rail-item] [data-md-navigation-indicator]::before'))
->toBe(['background-color' => 'var(--md-sys-color-on-secondary-container)', 'opacity' => 'var(--navigation-indicator-layer)']);
}); });
@@ -26,6 +26,9 @@ dataset('navigation components', [
'toolbar', 'toolbar',
'tabs', 'tabs',
'navigation-bar-item', 'navigation-bar-item',
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
]); ]);
/** /**