From bbc35ce5d822902109428a50e5a18d54d6fdbe15 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 12:14:50 +0200 Subject: [PATCH] feat: two-bar header, with the section bar out of the way while you read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven links, a search box, an exam switcher and a logout button were all competing in one 52px row. Two bars, as on the reference site: * the primary bar — identity, search, jobs, account — never moves; * the section bar carries the exam switcher and the sections, and leaves while you scroll down, returning on the way up. That bar is navigation: wanted at the moment you decide to go somewhere else, in the way for every moment in between. Scrolling up is the gesture that means "I am looking for something", so that is when it comes back. Three details it needs to not be annoying. It collapses its height rather than sliding away, so the page moves up with it instead of leaving a gap. Nothing hides within 90px of the top, so a short page never loses it. And a movement under 6px is treated as noise, because trackpads and momentum scrolling emit a great deal of one-pixel jitter — without that guard the bar flickers on a stationary page. Scroll handling is one animation frame per burst, and `:focus-within` keeps the bar open for a keyboard user tabbing into links that are visually gone. On a phone the burger keeps the sections and the strip hides: two copies of the same links competing for the same thumb is worse than one. The article contents are now a rail rather than a card — sticky for the article's whole length, scrolling on their own when there are more sections than screen, and collapsible to hand their width to the prose, which is what a wide table needs and nothing else on the page can give it. 196 frontend tests green, 7 of them new on the header. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z --- frontend/src/components/Navbar.jsx | 85 +++++++++--------- frontend/src/components/Navbar.test.jsx | 104 +++++++++++++++++++++++ frontend/src/hooks/useHidingBar.js | 43 ++++++++++ frontend/src/index.css | 44 ++++++++-- frontend/src/pages/ArticlesPage.css | 27 +++++- frontend/src/pages/ArticlesPage.jsx | 9 +- frontend/src/pages/ArticlesPage.test.jsx | 12 +++ 7 files changed, 278 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/Navbar.test.jsx create mode 100644 frontend/src/hooks/useHidingBar.js diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 4afb168..1db178c 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext' import api from '../api/client' import ExamSwitcher from './ExamSwitcher' import GlobalSearch from './GlobalSearch' +import useHidingBar from '../hooks/useHidingBar' function JobsBadge({ jobs }) { const [open, setOpen] = useState(false) @@ -62,6 +63,9 @@ function JobsBadge({ jobs }) { export default function Navbar({ onSignIn, onRegister }) { const { user, logout } = useAuth() const [menuOpen, setMenuOpen] = useState(false) + // The section bar is wanted when you decide to go elsewhere and in the way + // every other moment, so it leaves on the way down and returns on the way up. + const sectionBarHidden = useHidingBar() const [jobs, setJobs] = useState([]) const location = useLocation() const isModerator = user?.role === 'admin' || user?.role === 'moderator' @@ -116,35 +120,22 @@ export default function Navbar({ onSignIn, onRegister }) { return (
-
- setMenuOpen(false)}>🏥 PedsHub - {user && window.location.reload()} />} - {user && } + {/* Two bars, as on a reference site: who you are and what you are looking + for stays put; where you are going gets out of the way while you read. */} +
+
+ setMenuOpen(false)}>🏥 PedsHub + {user && } - {user ? ( - <> - {/* Desktop nav */} - - - {/* Mobile: jobs + hamburger */} -
+ {user ? ( +
+
- - ) : ( - /* Logged-out: Sign In + Register */ -
- {onSignIn - ? - : Sign In - } - {onRegister - ? - : Register - } -
- )} + ) : ( + /* Logged-out: Sign In + Register */ +
+ {onSignIn + ? + : Sign In + } + {onRegister + ? + : Register + } +
+ )} +
+ {user && ( + /* Focus-within keeps it open for a keyboard user tabbing into links + that are visually gone. */ +
+
+ window.location.reload()} /> + +
+
+ )} + {/* Mobile dropdown */} {user && menuOpen && (
({ default: { get: vi.fn(), post: vi.fn() } })) +let currentUser = { id: 1, name: 'Learner', role: 'user' } +const logout = vi.fn() +vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser, logout }) })) +vi.mock('./ExamSwitcher', () => ({ default: () =>
})) +vi.mock('./GlobalSearch', () => ({ default: () => })) + +const mount = () => render() + +const scrollTo = async (y) => { + window.scrollY = y + await act(async () => { + window.dispatchEvent(new Event('scroll')) + // The listener defers to an animation frame so a burst of events costs one pass. + await new Promise(resolve => requestAnimationFrame(() => resolve())) + }) +} + +describe('two-bar header', () => { + beforeEach(() => { + vi.clearAllMocks() + currentUser = { id: 1, name: 'Learner', role: 'user' } + api.get.mockResolvedValue({ data: [] }) + window.scrollY = 0 + }) + + it('separates identity and search from where you are going', async () => { + mount() + const primary = document.querySelector('.navbar-primary') + expect(within(primary).getByLabelText('Search PedsHub')).toBeInTheDocument() + expect(within(primary).getByText('🏥 PedsHub')).toBeInTheDocument() + + const sections = document.querySelector('.navbar-sections') + expect(within(sections).getByRole('navigation', { name: 'Sections' })).toBeInTheDocument() + expect(within(sections).getByTestId('exam-switcher')).toBeInTheDocument() + // The section links are not duplicated in the bar that never moves. + expect(within(primary).queryByRole('link', { name: 'Reading' })).not.toBeInTheDocument() + }) + + it('marks the section you are in', async () => { + mount() + const current = screen.getByRole('link', { name: 'Reading' }) + expect(current).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('link', { name: 'Cards' })).not.toHaveAttribute('aria-current') + }) + + it('gets out of the way going down and comes back coming up', async () => { + mount() + const bar = document.querySelector('.navbar-sections') + expect(bar).not.toHaveClass('is-hidden') + + await scrollTo(400) + expect(bar).toHaveClass('is-hidden') + + // Scrolling up is the gesture that means "I am looking for something". + await scrollTo(320) + expect(bar).not.toHaveClass('is-hidden') + }) + + it('stays put near the top, so a short page never loses it', async () => { + mount() + const bar = document.querySelector('.navbar-sections') + await scrollTo(40) + expect(bar).not.toHaveClass('is-hidden') + }) + + it('ignores the jitter a trackpad produces', async () => { + mount() + const bar = document.querySelector('.navbar-sections') + await scrollTo(400) + expect(bar).toHaveClass('is-hidden') + await scrollTo(402) // under the threshold: not a decision to scroll up + expect(bar).toHaveClass('is-hidden') + }) + + it('shows moderator-only sections only to those who manage questions', async () => { + mount() + await waitFor(() => expect(api.get).toHaveBeenCalledWith('/question-categories/my-grants')) + expect(screen.queryByRole('link', { name: 'Images' })).not.toBeInTheDocument() + + currentUser = { id: 2, name: 'Mod', role: 'moderator' } + mount() + expect(await screen.findByRole('link', { name: 'Images' })).toBeInTheDocument() + expect(screen.getAllByRole('link', { name: 'Manage Qs' })[0]).toBeInTheDocument() + }) + + it('keeps the phone menu to one copy of the links', async () => { + mount() + expect(screen.queryByRole('link', { name: 'Study plans' })).toBeInTheDocument() + await userEvent.click(screen.getByLabelText('Menu')) + // Opening the burger adds the mobile copy; both exist in the DOM and CSS + // shows one, so the count is what proves there is no third list. + expect(screen.getAllByRole('link', { name: 'Study plans' })).toHaveLength(2) + // One in the bar for a wide screen, one in the burger for a narrow one. + expect(screen.getAllByRole('button', { name: 'Logout' })).toHaveLength(2) + }) +}) diff --git a/frontend/src/hooks/useHidingBar.js b/frontend/src/hooks/useHidingBar.js new file mode 100644 index 0000000..4c3734e --- /dev/null +++ b/frontend/src/hooks/useHidingBar.js @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * Hide something while the reader is scrolling down, bring it back on the way up. + * + * The section bar is navigation: wanted at the moment you decide to go somewhere + * else, in the way for every moment in between. Scrolling up is the gesture that + * means "I am looking for something", so that is when it comes back. + * + * Two guards stop it flickering: nothing hides until you are past `offset`, so a + * short page never loses it, and a movement under `threshold` is treated as + * noise — trackpads and momentum scrolling emit a lot of one-pixel jitter. + */ +export default function useHidingBar({ offset = 90, threshold = 6 } = {}) { + const [hidden, setHidden] = useState(false) + const lastY = useRef(0) + + useEffect(() => { + if (typeof window === 'undefined') return + lastY.current = window.scrollY + let frame = 0 + + const onScroll = () => { + if (frame) return + frame = window.requestAnimationFrame(() => { + frame = 0 + const y = window.scrollY + const delta = y - lastY.current + if (Math.abs(delta) < threshold) return + lastY.current = y + setHidden(y > offset && delta > 0) + }) + } + + window.addEventListener('scroll', onScroll, { passive: true }) + return () => { + window.removeEventListener('scroll', onScroll) + if (frame) window.cancelAnimationFrame(frame) + } + }, [offset, threshold]) + + return hidden +} diff --git a/frontend/src/index.css b/frontend/src/index.css index a156b90..f74af73 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -114,12 +114,41 @@ body { .container { max-width: 1200px; margin: 0 auto; padding: 0 28px; } /* ── Navbar ─────────────────────────────────────────────────── */ -.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 0 0 0; margin-bottom: 32px; position: sticky; top: 0; z-index: 50; } +/* Two bars. The primary one — identity, search, account — never moves. The + section bar leaves while you scroll down and returns on the way up: it is + wanted at the moment you decide to go elsewhere, and in the way in between. */ +.navbar { color: var(--navbar-fg); margin-bottom: 32px; position: sticky; top: 0; z-index: 50; } +.navbar-primary { background: var(--navbar-bg); } .navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; gap: 14px; height: 52px; } .navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; } + +.navbar-account { display: flex; align-items: center; gap: 8px; } + +.navbar-sections { + background: var(--card-bg); + border-bottom: 1px solid var(--border); + height: 46px; + overflow: hidden; + transition: height 0.18s ease; +} +/* Collapsing the height rather than sliding it away, so the page moves up with + it instead of leaving a gap where the bar used to be. */ +.navbar-sections.is-hidden { height: 0; border-bottom-color: transparent; } +.navbar-sections:focus-within { height: 46px; } +.navbar-sections-inner { display: flex; align-items: center; gap: 14px; height: 46px; } + +.nav-sections { display: flex; align-items: center; gap: 2px; overflow-x: auto; scrollbar-width: none; } +.nav-sections::-webkit-scrollbar { display: none; } +.navbar .nav-sections a { + color: var(--text-muted); font-size: 0.84rem; padding: 7px 11px; + border-radius: 7px; white-space: nowrap; +} +.navbar .nav-sections a:hover { background: var(--bg); color: var(--text); opacity: 1 !important; } +.navbar .nav-sections a.is-current { color: var(--primary); font-weight: 650; background: var(--option-sel-bg); } + +@media (prefers-reduced-motion: reduce) { .navbar-sections { transition: none; } } [data-theme="markdown"] .navbar .logo { color: #d4a96a; } -.nav-desktop { display: flex; gap: 4px; align-items: center; } -.nav-mobile-controls { display: none; align-items: center; gap: 8px; } +.nav-burger { display: none; background: none !important; border: 0 !important; cursor: pointer; color: var(--navbar-fg); padding: 6px; flex-direction: column; gap: 5px; opacity: 0.85; } .navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.83rem; padding: 6px 10px; border-radius: 6px; white-space: nowrap; transition: opacity 0.15s, background 0.15s; } .navbar a:hover { background: rgba(255,255,255,0.09); opacity: 1 !important; } .navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; white-space: nowrap; font-family: inherit; } @@ -667,8 +696,13 @@ body { .card { padding: 18px 16px; } .navbar .navbar-inner { height: 48px; } /* Mobile: show hamburger, hide desktop nav */ - .nav-desktop { display: none; } - .nav-mobile-controls { display: flex; } + /* The burger carries the sections on a phone; the strip would be a second + copy of the same links competing for the same thumb. */ + .nav-burger { display: flex; } + .nav-logout { display: none; } + .navbar-sections .nav-sections { display: none; } + .navbar-sections, .navbar-sections-inner, .navbar-sections:focus-within { height: 42px; } + .navbar-sections.is-hidden { height: 0; } /* Mobile quiz: hide sidebar, show toggle */ .quiz-sidebar { display: none; } .quiz-nav-toggle { display: inline-flex; } diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index c3a78b1..398ab09 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -16,8 +16,29 @@ .article-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .article-header-actions { display: flex; gap: 8px; } .article-layout { display: grid; grid-template-columns: 240px 1fr; gap: 20px; align-items: start; } -.article-sections { position: sticky; top: 80px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 14px; } +/* A rail, not a card: the contents of a long article should stay in view for + its whole length, scrolling on their own when there are more sections than + screen. */ +.article-sections { + position: sticky; top: 76px; max-height: calc(100vh - 96px); overflow-y: auto; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--card-radius); padding: 14px; +} .article-sections h4 { margin: 0 0 8px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); } +/* Collapsing it hands the width to the prose, which is what a long table or a + wide image needs and what nothing else on the page can give it. */ +.article-layout.is-railed-off { grid-template-columns: 34px 1fr; } +.article-layout.is-railed-off .article-sections { padding: 6px; } +.article-layout.is-railed-off .article-sections h4, +.article-layout.is-railed-off .article-sections ul { display: none; } +.article-rail-toggle { + display: flex; align-items: center; justify-content: center; + width: 100%; min-height: 34px; margin-bottom: 8px; padding: 4px; + background: none; border: 1px solid var(--border); border-radius: 8px; + color: var(--text-muted); font: inherit; font-size: 1rem; cursor: pointer; +} +.article-rail-toggle:hover { border-color: var(--primary); color: var(--primary); } +.article-layout.is-railed-off .article-rail-toggle { margin-bottom: 0; } .article-sections ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } .section-link { width: 100%; text-align: left; background: none; border: none; border-radius: 6px; padding: 7px 10px; font-size: .85rem; color: var(--text); cursor: pointer; } .section-link:hover { background: var(--hover, #eef4fb); } @@ -37,7 +58,9 @@ .article-edit .form-label { margin-top: 6px; } @media (max-width: 820px) { .article-layout { grid-template-columns: 1fr; } - .article-sections { position: static; display: none; } + .article-sections { position: static; display: none; max-height: none; } + .article-rail-toggle { display: none; } + .article-layout.is-railed-off { grid-template-columns: 1fr; } .article-sections.open { display: block; } .article-drawer-toggle { display: inline-block; } .article-content { padding: 16px; } diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 365a3a6..f427fbd 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -208,6 +208,8 @@ export function ArticlePage() { // you consult, and a wall of prose hides the one heading you came for. const [openIds, setOpenIds] = useState({}) const [drawerOpen, setDrawerOpen] = useState(false) + // Collapsing the contents rail hands its width to the prose. + const [railOpen, setRailOpen] = useState(true) const [questions, setQuestions] = useState([]) const [cards, setCards] = useState([]) const [editing, setEditing] = useState(searchParams.get('edit') === '1') @@ -470,11 +472,16 @@ export function ArticlePage() {
) : ( -
+