feat: two-bar header, with the section bar out of the way while you read
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
This commit is contained in:
parent
8a07703ee2
commit
bbc35ce5d8
7 changed files with 278 additions and 46 deletions
|
|
@ -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 (
|
||||
<div className="navbar">
|
||||
<div className="container navbar-inner">
|
||||
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
|
||||
{user && <ExamSwitcher onChange={() => window.location.reload()} />}
|
||||
{user && <GlobalSearch />}
|
||||
{/* 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. */}
|
||||
<div className="navbar-primary">
|
||||
<div className="container navbar-inner">
|
||||
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
|
||||
{user && <GlobalSearch />}
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{/* Desktop nav */}
|
||||
<nav className="nav-desktop">
|
||||
{navLinks.map(l => (
|
||||
<Link key={l.to} to={l.to}
|
||||
style={{ fontWeight: location.pathname === l.to ? 600 : 400, opacity: location.pathname === l.to ? 1 : 0.7 }}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<JobsBadge jobs={jobs} />
|
||||
<button onClick={logout}>Logout</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile: jobs + hamburger */}
|
||||
<div className="nav-mobile-controls">
|
||||
{user ? (
|
||||
<div className="navbar-account">
|
||||
<JobsBadge jobs={jobs} />
|
||||
<button className="nav-logout" onClick={logout}>Logout</button>
|
||||
<button
|
||||
className="nav-burger"
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
aria-label="Menu"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--navbar-fg)',
|
||||
padding: '6px', display: 'flex', flexDirection: 'column', gap: 5, opacity: 0.85,
|
||||
}}
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span style={{
|
||||
display: 'block', width: 22, height: 2, background: 'currentColor',
|
||||
|
|
@ -162,22 +153,40 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Logged-out: Sign In + Register */
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onSignIn
|
||||
? <button onClick={onSignIn} style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, background: 'none', border: 'none', cursor: 'pointer', padding: '6px 10px' }}>Sign In</button>
|
||||
: <Link to="/login" style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, textDecoration: 'none', padding: '6px 10px' }}>Sign In</Link>
|
||||
}
|
||||
{onRegister
|
||||
? <button onClick={onRegister} className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', borderRadius: 8 }}>Register</button>
|
||||
: <Link to="/register" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', textDecoration: 'none', borderRadius: 8 }}>Register</Link>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
/* Logged-out: Sign In + Register */
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onSignIn
|
||||
? <button onClick={onSignIn} style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, background: 'none', border: 'none', cursor: 'pointer', padding: '6px 10px' }}>Sign In</button>
|
||||
: <Link to="/login" style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, textDecoration: 'none', padding: '6px 10px' }}>Sign In</Link>
|
||||
}
|
||||
{onRegister
|
||||
? <button onClick={onRegister} className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', borderRadius: 8 }}>Register</button>
|
||||
: <Link to="/register" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', textDecoration: 'none', borderRadius: 8 }}>Register</Link>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
/* Focus-within keeps it open for a keyboard user tabbing into links
|
||||
that are visually gone. */
|
||||
<div className={`navbar-sections${sectionBarHidden ? ' is-hidden' : ''}`}>
|
||||
<div className="container navbar-sections-inner">
|
||||
<ExamSwitcher onChange={() => window.location.reload()} />
|
||||
<nav className="nav-sections" aria-label="Sections">
|
||||
{navLinks.map(l => (
|
||||
<Link key={l.to} to={l.to} className={location.pathname === l.to ? 'is-current' : undefined}
|
||||
aria-current={location.pathname === l.to ? 'page' : undefined}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile dropdown */}
|
||||
{user && menuOpen && (
|
||||
<div style={{
|
||||
|
|
|
|||
104
frontend/src/components/Navbar.test.jsx
Normal file
104
frontend/src/components/Navbar.test.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Navbar from './Navbar'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ 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: () => <div data-testid="exam-switcher" /> }))
|
||||
vi.mock('./GlobalSearch', () => ({ default: () => <input aria-label="Search PedsHub" /> }))
|
||||
|
||||
const mount = () => render(<MemoryRouter initialEntries={['/articles']}><Navbar /></MemoryRouter>)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
43
frontend/src/hooks/useHidingBar.js
Normal file
43
frontend/src/hooks/useHidingBar.js
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="article-layout">
|
||||
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}`}>
|
||||
<button className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)} aria-expanded={drawerOpen} aria-controls="article-sections">
|
||||
{drawerOpen ? '✕ Close sections' : '☰ Sections'}
|
||||
</button>
|
||||
<aside id="article-sections" className={`article-sections ${drawerOpen ? 'open' : ''}`}>
|
||||
<button type="button" className="article-rail-toggle" aria-expanded={railOpen}
|
||||
aria-label={railOpen ? 'Collapse contents' : 'Show contents'}
|
||||
onClick={() => setRailOpen(v => !v)}>
|
||||
<span aria-hidden="true">{railOpen ? '‹' : '›'}</span>
|
||||
</button>
|
||||
<h4>{article.title}</h4>
|
||||
<ul className="atoc">
|
||||
{topSections.map(sec => (
|
||||
|
|
|
|||
|
|
@ -173,6 +173,18 @@ describe('topic reading', () => {
|
|||
expect(screen.queryByText('First body')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses the contents rail, handing its width to the prose', async () => {
|
||||
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
|
||||
await screen.findByText('Introduction markdown')
|
||||
const layout = document.querySelector('.article-layout')
|
||||
expect(layout).not.toHaveClass('is-railed-off')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Collapse contents' }))
|
||||
expect(layout).toHaveClass('is-railed-off')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show contents' }))
|
||||
expect(layout).not.toHaveClass('is-railed-off')
|
||||
})
|
||||
|
||||
it('nests a sub-section under its parent and keeps references last', async () => {
|
||||
const nested = { ...article, sections: [
|
||||
{ id: 'a'.repeat(32), slug: 'ros', title: 'Review of systems', content: 'ROS body' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue