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 =
+
+ ;
+
+ 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