diff --git a/app/src/App.css b/app/src/App.css index a72f0a58..ddc10ad3 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -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; } diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js index 8dfce4f4..a74cf7fd 100644 --- a/app/src/components/Nav.js +++ b/app/src/components/Nav.js @@ -439,14 +439,7 @@ export function NavBar() { return <> - + @@ -454,6 +447,17 @@ export function NavBar() { } +export function MobileNav({navColors, homeLink, icons}) { + return +} + export function DesktopNav({navColors, homeLink, icons}) { const containerRef = React.useRef(null); const homeRef = React.useRef(null); diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index 7ed1e919..616668a0 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -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 = <> + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + + + + {/* No size inside icon={}: the icon-name audit in ui.test.js reads every string + literal in such an expression as an icon name. */} + + }/> + + + + + ; + + const homeLink = + WROLPi Home Icon + ; + + 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( +
+ +
, + )); + }; + + // 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