diff --git a/frontend/src/components/CoachMarks.jsx b/frontend/src/components/CoachMarks.jsx index b5a763d..84c9271 100644 --- a/frontend/src/components/CoachMarks.jsx +++ b/frontend/src/components/CoachMarks.jsx @@ -80,6 +80,26 @@ export default function CoachMarks({ steps = [], onDone }) { } }, [place]) + // Bring the target to the reader before pointing at it. On a phone the third + // step is usually below the fold, and a coach mark for something off-screen + // is a card explaining a thing that is not there — you are told to look at + // the analysis and shown the top of the page. + // + // `block: 'center'` rather than `nearest`: the card sits under the target + // where there is room, so a target scrolled to the very bottom edge has + // nowhere to put its own explanation. + useEffect(() => { + if (!step) return + const node = document.querySelector(`[data-tour="${step.target}"]`) + if (!node?.scrollIntoView) return + const box = node.getBoundingClientRect() + const room = window.innerHeight + // Already comfortably in view: scrolling anyway would jerk the page under + // somebody who can see the thing perfectly well. + if (box.top >= 8 && box.bottom <= room - 8) return + node.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }) + }, [step]) + useEffect(() => { card.current?.focus() }, [index]) const finish = useCallback(() => { remember(); onDone?.() }, [onDone]) diff --git a/frontend/src/components/CoachMarks.test.jsx b/frontend/src/components/CoachMarks.test.jsx index 80586fa..dec8728 100644 --- a/frontend/src/components/CoachMarks.test.jsx +++ b/frontend/src/components/CoachMarks.test.jsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import CoachMarks, { tourSeen } from './CoachMarks' @@ -77,3 +77,33 @@ describe('the first-run walk', () => { expect(onDone).toHaveBeenCalled() }) }) + +it('brings a target below the fold to the reader before pointing at it', async () => { + // On a phone the last step is usually off-screen, and a coach mark for + // something nobody can see is a card explaining a thing that is not there. + const seen = [] + const target = document.createElement('div') + target.setAttribute('data-tour', 'below') + target.getBoundingClientRect = () => ({ top: 1400, left: 0, bottom: 1480, right: 300, width: 300, height: 80 }) + target.scrollIntoView = (options) => seen.push(options) + document.body.appendChild(target) + + render( {}} />) + await waitFor(() => expect(seen).toHaveLength(1)) + expect(seen[0]).toMatchObject({ block: 'center' }) + target.remove() +}) + +it('leaves the page alone when the target is already in view', async () => { + const seen = [] + const target = document.createElement('div') + target.setAttribute('data-tour', 'here') + target.getBoundingClientRect = () => ({ top: 100, left: 0, bottom: 180, right: 300, width: 300, height: 80 }) + target.scrollIntoView = (options) => seen.push(options) + document.body.appendChild(target) + + render( {}} />) + await screen.findByText('Right here') + expect(seen).toHaveLength(0) + target.remove() +}) diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index db8bc91..a231e2e 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -281,6 +281,16 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { for stays put; where you are going gets out of the way while you read. */}
+ {/* While a session is open on a phone, the questions get their own + control on the left and the site menu keeps the right. One burger + that changed meaning depending on where you were meant the two + menus took turns, and the one you wanted was the other one. */} + {sessionDrawer.opener && ( + + )} setMenuOpen(false)} onMouseEnter={() => setPeek(true)} onFocus={() => setPeek(true)}>🏥 PedsHub {user && } @@ -290,16 +300,15 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { - {/* While a session is open on a narrow screen, this button - belongs to the session: it opens the list of questions, and - the site menu is a tab inside that drawer. Two menu buttons - an inch apart, one of which leaves the session you are - sitting, is the wrong offer. */} + {/* Always the site menu. It used to become the session's list of + questions while a session was open, which put both menus on + the same button and neither where the thumb expected it; the + session now has its own control at the other end of the bar. */}