diff --git a/app/src/App.css b/app/src/App.css index 5f68e862..6a8cc2c1 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -1,3 +1,8 @@ +:root { + /* Shared with the toast offset below, so the two cannot drift apart. */ + --navbar-height: 3.4em; +} + /* Nav bar: a hand-rolled flexbox bar (was a Semantic Menu). The user-chosen background and the foreground measured against it are set inline (see themes/navColors); this stylesheet only supplies structure and hover affordances. */ @@ -10,10 +15,30 @@ * is the one link a user needs to reach the settings that fix a broken WROLPi. */ flex-wrap: wrap; - min-height: 3.4em; + min-height: var(--navbar-height); padding: 0 0.25em; } +/* + * Toasts start below the navigation bar instead of on top of it. + * + * They are fixed at `top: var(--mantine-spacing-md)` -- 16px -- while the bar occupies the + * first 54px of the page, so every toast landed over the bar's right-hand corner: the search + * glyph, the hamburger, Help and Admin. For as long as the toast was up those controls could + * not be clicked, and an error toast is exactly when a user reaches for Admin. + * + * It went unnoticed until the search glyph was given a proper hit area: at 18px it sat in the + * gap under the toast, and growing it to the height of the bar moved it into the overlap. + * The toast was always covering that corner; there was just nothing there to hit. + * + * `:where()` gives Mantine's own rule zero specificity, so one class is enough to win, but + * the attribute is matched too -- only the top-anchored positions need moving, and a future + * switch to `bottom-right` should not silently inherit a top offset. + */ +html .mantine-Notifications-root[data-position^='top'] { + top: calc(var(--navbar-height) + var(--mantine-spacing-md, 16px)); +} + .wrolpi-navbar-right { display: flex; align-items: center; diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index b64a7b87..c1371943 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -2745,3 +2745,95 @@ describe('the global search modal shows its results', () => { }); }); }); + +describe('a toast does not cover the navigation bar', () => { + /* + * Toasts are fixed at `top: var(--mantine-spacing-md)` -- 16px -- while the bar occupies + * the first 54px of the page, so every toast landed over the bar's right-hand corner: + * the search glyph, the hamburger, Help and Admin. For as long as it was up those + * controls could not be clicked, and an error toast is exactly the moment a user reaches + * for Admin. + * + * It surfaced as six e2e failures the day the search glyph was given a proper hit area: + * at 18px it sat in the gap beneath the toast, and growing it to the height of the bar + * moved it into the overlap. The toast had been covering that corner all along; there + * was simply nothing there to hit. + * + * Measured as `elementFromPoint`, which is the same question Cypress's actionability + * check asks and the one a mouse asks -- a geometry-only comparison would miss a toast + * that overlaps but sits behind, and a z-index comparison would miss one that is in + * front but elsewhere on the screen. + */ + const themeIcon = + + ; + + const mountBarWithToast = () => { + cy.mountUI( + + WROLPi} + icons={themeIcon} + /> + ); + cy.then(() => { + clearToasts(); + // The shape CI produces: an error toast, which is the kind that lingers. + toast({type: 'error', title: 'Unexpected error', description: 'Something failed.'}); + }); + cy.get('.mantine-Notification-root').should('exist'); + }; + + it('leaves the search control clickable while a toast is up', () => { + mountBarWithToast(); + + cy.get('.wrolpi-navbar [aria-label="Search"]').should(($search) => { + const box = $search[0].getBoundingClientRect(); + const topmost = document.elementFromPoint( + box.left + box.width / 2, box.top + box.height / 2); + + expect(topmost, 'something is at the search glyph').to.not.be.null; + expect($search[0].contains(topmost) || topmost === $search[0], + `search is the topmost element, not ${topmost && topmost.className}`).to.be.true; + }); + }); + + it('leaves every control in the corner clickable', () => { + // Help and Admin are the ones that matter most: an error toast is when a user goes + // looking for the admin pages, and it was sitting on top of the link. + mountBarWithToast(); + + cy.get('.wrolpi-navbar-right').should(($corner) => { + const controls = [...$corner[0].querySelectorAll('a, button')]; + expect(controls.length, 'there are controls to check').to.be.at.least(3); + + const covered = controls.filter((control) => { + const box = control.getBoundingClientRect(); + const topmost = document.elementFromPoint( + box.left + box.width / 2, box.top + box.height / 2); + return !(control.contains(topmost) || topmost === control); + }).map(control => control.textContent.trim() + || control.getAttribute('aria-label') || '(unlabelled)'); + + expect(covered).to.deep.equal([]); + }); + }); + + it('starts the toast below the bar rather than beside it', () => { + /* + * The mechanism, stated separately from the effect. The two cases above would also + * pass if the toast merely happened to be narrow enough to miss the controls at this + * viewport, which is how the bar came to be covered at some widths and not others. + */ + mountBarWithToast(); + + cy.get('.mantine-Notifications-root').should(($root) => { + const toastTop = $root[0].getBoundingClientRect().top; + const barBottom = Cypress.$('.wrolpi-navbar')[0].getBoundingClientRect().bottom; + + expect(barBottom, 'the bar has height to clear').to.be.greaterThan(40); + expect(toastTop, 'the toast starts below the bar').to.be.at.least(barBottom); + }); + }); +});