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() {
) : ( -
+