app: the navbar corner wrapped onto a second row on small phones (#672)

* app: the navbar corner wrapped onto a second row on small phones

Every status-icon slot carried a 1.5em separator margin -- ~80px across
three slots -- which wrapped the mobile bar's whole corner onto a second
row on any screen narrower than about 380px. The margin is now 0.25em on
both bars: the corner's gap and the slots' own padding still space the
icons, and on desktop the freed room just goes back to the links that
useOverflowNav folds into "More".

The mobile bar is extracted as MobileNav (mirroring DesktopNav) so a
Cypress suite can mount it and hold it to one row at phone widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: prove 320px with a faithful fixture, trim the CSS narration

The suite claimed 360px covered narrower devices because the fixture was
wider than production, but the fixture also lacked the real theme
toggle's 0.8em right margin, so the safety margin was overstated. The
fixture now matches a phone -- 32px logo home link, the toggle's margin,
and a coarse pointer so the search link drops its keyboard hint, with a
test asserting the hint is really absent -- and 320px is measured
directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-08-20 18:37:23 -06:00 • committed by GitHub
parent 42595400b4
commit e66e1e9309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 131 additions and 10 deletions

View file

@ -143,7 +143,9 @@ html .wrolpi-navbar .mantine-ActionIcon-root:hover {
.wrolpi-navbar-icon {
display: flex;
align-items: center;
margin-left: 1.5em;
/* A sliver only: the corner's gap and the slots' own padding already separate the
icons, and anything more has to fit a 320px phone times the number of slots. */
margin-left: 0.25em;
/* Full bar height, so the anchor inside has somewhere to stretch to. */
align-self: stretch;
}

View file

@ -439,14 +439,7 @@ export function NavBar() {
return <>
<Media between={['mobile', 'tablet']}>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
{homeLink}
<div className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
<MobileMenu links={[...allLinks, ...rightLinks]}/>
</div>
</nav>
<MobileNav navColors={navColors} homeLink={homeLink} icons={icons}/>
</Media>
<Media greaterThanOrEqual='tablet'>
<DesktopNav navColors={navColors} homeLink={homeLink} icons={icons}/>
@ -454,6 +447,17 @@ export function NavBar() {
</>
}
export function MobileNav({navColors, homeLink, icons}) {
return <nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
{homeLink}
<div className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
<MobileMenu links={[...allLinks, ...rightLinks]}/>
</div>
</nav>
}
export function DesktopNav({navColors, homeLink, icons}) {
const containerRef = React.useRef(null);
const homeRef = React.useRef(null);

View file

@ -13,7 +13,7 @@ import {clearToasts, toast} from './toast';
import {isDarkTheme, monochromeThemes, themeNames} from '../../themes/names';
import {navColorNames} from '../../themes/navColors';
import {NavBarSample} from '../ThemeSamplePage';
import {DesktopNav, NavIconWrapper} from '../Nav';
import {DesktopNav, MobileNav, NavIconWrapper} from '../Nav';
import {APIButton} from '../Common';
import {ShareButton} from '../Share';
import {SortableTable} from '../SortableTable';
@ -3734,6 +3734,121 @@ describe('the navigation bar never breaks onto a second line', () => {
});
});
describe('the mobile navigation bar fits a phone on one row', () => {
/*
* The mobile bar showed the logo on one row and the whole corner -- share, hotspot,
* theme, search, hamburger -- on a second, on any screen narrower than about 380px.
* Every icon slot carried a 1.5em separator margin, which is ~80px across three
* slots: more than the deficit. The slot margin is small now (`.wrolpi-navbar-icon`),
* and this holds the bar to fitting a phone whatever the margin becomes.
*/
/*
* The fixture matches what a phone actually renders, so the widths below are the widths
* being proven -- not a stand-in with different contents:
* - the home link is the 32px logo, the default (`NAME` is null in production);
* - the theme slot carries the 0.8em right margin the real DarkModeToggle has;
* - the pointer is made coarse (below), which is what makes a phone drop the search
* link's keyboard hint.
*/
const indicators = <>
<NavIconWrapper>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href='#' data-testid='share'><Icon name='share' size='large'/></a>
</NavIconWrapper>
<NavIconWrapper>
{/* No size inside icon={}: the icon-name audit in ui.test.js reads every string
literal in such an expression as an icon name. */}
<IconButton data-testid='hotspot' label='Hotspot' variant='subtle' icon={() =>
<Icon name='wifi'/>}/>
</NavIconWrapper>
<NavIconWrapper>
<IconButton data-testid='theme' icon='sun' label='Theme' variant='subtle'
style={{marginRight: '0.8em'}}/>
</NavIconWrapper>
</>;
const homeLink = <a className='wrolpi-navbar-link' href='#'
style={{paddingTop: 0, paddingBottom: 0}}>
<i><img src='/icon.svg' height='32px' width='32px' alt='WROLPi Home Icon'/></i>
</a>;
const mountAt = (width) => {
/*
* A phone has a coarse pointer. useKeyboardDetected keys off that and never turns
* true without a keypress, so SearchIconButton drops its keyboard hint -- on the
* desktop browser running this spec the hint renders and widens the corner by a
* hint the phone never shows. Other queries pass through untouched; Mantine asks
* its own. Guarded because a stub cannot wrap a method twice in one test.
*/
cy.window().then((win) => {
if (win.matchMedia.restore) return;
const real = win.matchMedia.bind(win);
cy.stub(win, 'matchMedia').callsFake((query) => query === '(pointer: coarse)'
? {
matches: true, media: query, onchange: null,
addEventListener: () => undefined, removeEventListener: () => undefined,
addListener: () => undefined, removeListener: () => undefined,
dispatchEvent: () => false,
}
: real(query));
});
return cy.viewport(width, 700).then(() => cy.mountUI(
<MemoryRouter><div style={{width}}>
<MobileNav
navColors={{background: '#5c4fa8', color: '#ffffff', ratio: 6.69}}
homeLink={homeLink}
icons={indicators}
/>
</div></MemoryRouter>,
));
};
// Wide enough that nothing can wrap, so this is the height of exactly one row.
let singleRow;
before(() => {
mountAt(700);
cy.get('.wrolpi-navbar').then(($nav) => {
singleRow = $nav[0].getBoundingClientRect().height;
expect(singleRow, 'a single row has a height to compare against')
.to.be.greaterThan(30);
});
});
it('renders what a phone renders: no keyboard hint on the search link', () => {
// The teeth for the coarse-pointer stub. If the hint ever renders here, the widths
// below are being proven against a wider bar than any phone shows.
mountAt(375);
cy.get('.wrolpi-navbar [aria-label="Search"]').should(($search) => {
expect($search.text().trim(), 'search link shows a glyph only').to.equal('');
});
});
it('stays one row at small phone widths', () => {
// 375px is the iPhone SE class that wrapped; 320px is the original iPhone SE.
[414, 375, 320].forEach((width) => {
mountAt(width);
cy.get('.wrolpi-navbar').should(($nav) => {
expect($nav[0].getBoundingClientRect().height, `bar height at ${width}px`)
.to.be.at.most(singleRow + 2);
});
});
});
it('keeps the whole corner inside the bar rather than clipping it', () => {
// The failure a nowrap fix would trade the second row for.
[375, 320].forEach((width) => {
mountAt(width);
cy.get('.wrolpi-navbar').should(($nav) => {
const bar = $nav[0].getBoundingClientRect();
const corner = $nav[0].querySelector('.wrolpi-navbar-right');
expect(corner.getBoundingClientRect().right, `corner at ${width}px`)
.to.be.at.most(bar.right + 0.5);
});
});
});
});
describe('the navbar overflow menu keeps the bar\'s styling', () => {
/*
* Mantine's Menu.Target clones its child and hands it a className of its own. The