feat: the question gets the room — a rail that closes, labs beside it,
three controls at the foot The rail is a sidebar now rather than furniture: a long stem wants the width, and the list is one press away on a handle that sits where the rail was. Remembered, because somebody who put it away meant it. Labs moved off the top toolbar onto the question's own bar, beside the attending tip, and open as a column next to the case instead of a dialog over it — a reference range is read while re-reading the stem, and a dialog covers the thing it is for. Below 1150px there is no room for a third column and it goes back to being a dialog. The calculator is the exam's alone. A study session has no clock to beat and no arithmetic to do under one; both it and the old Lab values button were in that toolbar because the exam player and the study player were one screen. And the bar at the foot is three things: leave, back, on. The question count and the list behind it are in the rail, and on a narrow screen behind the site's own menu button — repeating them crowded the one row that has to stay legible under every question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
acb954972a
commit
f8a81ef937
4 changed files with 130 additions and 17 deletions
|
|
@ -52,7 +52,8 @@ const labFields = [
|
|||
['article_id', 'Article ID (optional deep link)', 12], ['article_section_id', 'Article section ID (optional)', 64],
|
||||
]
|
||||
|
||||
function LabValues() {
|
||||
/** Exported so the player can dock it beside a question instead of over it. */
|
||||
export function LabValues() {
|
||||
const { user } = useAuth()
|
||||
const isEducator = ['admin', 'moderator'].includes(user?.role)
|
||||
const [rows, setRows] = useState([])
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import FeedbackForm from '../components/FeedbackForm'
|
|||
import ShareSession from '../components/ShareSession'
|
||||
import MoreMenu from '../components/MoreMenu'
|
||||
import '../components/Feedback.css'
|
||||
import QuizTools, { QuizDialog } from '../components/QuizTools'
|
||||
import QuizTools, { QuizDialog, LabValues } from '../components/QuizTools'
|
||||
import './QuizPlayer.css'
|
||||
|
||||
//: Seconds left when the block says so. Long enough to finish the question in
|
||||
|
|
@ -476,6 +476,20 @@ export default function QuizPage() {
|
|||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||
const [manualHighlights, setManualHighlights] = useState({})
|
||||
const [tool, setTool] = useState(null)
|
||||
// Labs sit beside the question rather than over it: a reference range is
|
||||
// read while re-reading the case, and a dialog covers the thing it is for.
|
||||
const [labsOpen, setLabsOpen] = useState(false)
|
||||
// The rail is a sidebar, not furniture: on a long stem the question wants
|
||||
// the width, and the list is still one press away. Remembered, because
|
||||
// somebody who put it away meant it.
|
||||
const [railOpen, setRailOpen] = useState(() => {
|
||||
try { return localStorage.getItem('pedshub.quizRail') !== 'closed' } catch { return true }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('pedshub.quizRail', railOpen ? 'open' : 'closed') }
|
||||
catch { /* private browsing */ }
|
||||
}, [railOpen])
|
||||
// Which of the per-question panels is open. One at a time: they sit in the
|
||||
// same place under the stem, and two at once would push the options off screen.
|
||||
const [panel, setPanel] = useState(null)
|
||||
|
|
@ -1359,15 +1373,10 @@ const timerStarted = timeLeft !== null
|
|||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||
disabled={currentIdx === 0}>← Prev</button>
|
||||
|
||||
{/* Only where the rail is not on screen. Beside a permanent list of
|
||||
every question, a button that unfolds the same list is noise. */}
|
||||
{!hasRail && (
|
||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||
aria-label={`Question ${currentIdx + 1} of ${totalCount} — open the session`}
|
||||
onClick={() => { setDrawerTab('questions'); setNavOpen(true) }}>
|
||||
{currentIdx + 1} / {totalCount} ☰
|
||||
</button>
|
||||
)}
|
||||
{/* The bar is three things: leave, back, on. The question count and the
|
||||
list behind it live in the rail and, on a narrow screen, behind the
|
||||
site's own menu button — repeating them here crowded the one row
|
||||
that has to stay legible at the foot of every question. */}
|
||||
|
||||
{isLast && !reviewing ? (
|
||||
// The end of the block. In an exam the dialog names how many are still
|
||||
|
|
@ -1691,8 +1700,16 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column layout: content + desktop sidebar */}
|
||||
<div className="quiz-layout">
|
||||
{/* The question, with the rail on one side and the labs on the other —
|
||||
both optional, and the question taking whatever they leave. */}
|
||||
<div className={`quiz-layout${railOpen ? '' : ' is-rail-closed'}${labsOpen ? ' has-labs' : ''}`}>
|
||||
{/* Rendered whenever the rail is away, and hidden by the same
|
||||
breakpoint that hides the rail — the sidebar is a CSS decision, and
|
||||
its handle has to be made the same way or the two disagree. */}
|
||||
{!railOpen && (
|
||||
<button type="button" className="quiz-rail-reopen" aria-label="Show the session questions"
|
||||
onClick={() => setRailOpen(true)}>›</button>
|
||||
)}
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="quiz-topbar">
|
||||
|
|
@ -1731,8 +1748,14 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
<div className="quiz-top-actions">
|
||||
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}>⌨ <span>Shortcuts</span></button>
|
||||
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}>▦ <span>Calculator</span></button>
|
||||
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}>⚗ <span>Lab values</span></button>
|
||||
{/* Both are the exam's. A study session has no clock to beat, and
|
||||
the labs are on the question's own bar where the case is. */}
|
||||
{!isStudy && (
|
||||
<>
|
||||
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}>▦ <span>Calculator</span></button>
|
||||
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}>⚗ <span>Lab values</span></button>
|
||||
</>
|
||||
)}
|
||||
{/* An exam moves between items from the middle of this bar, and
|
||||
ends the block from the bar at the foot of it. Repeating
|
||||
either here is the same action under a second name. */}
|
||||
|
|
@ -1775,6 +1798,14 @@ const timerStarted = timeLeft !== null
|
|||
/>
|
||||
</div>
|
||||
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
|
||||
{/* Beside the tip rather than up in the toolbar: reference
|
||||
ranges are read against the case in front of you, and a
|
||||
control at the top of the screen is a different place from
|
||||
where the numbers are. */}
|
||||
<button type="button" className={labsOpen ? 'is-on' : ''}
|
||||
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
|
||||
⚗ <span>Labs</span>
|
||||
</button>
|
||||
{current.attending_tip && (
|
||||
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
||||
aria-pressed={panel === 'tip'}
|
||||
|
|
@ -2129,9 +2160,24 @@ const timerStarted = timeLeft !== null
|
|||
|
||||
</div>
|
||||
|
||||
{labsOpen && (
|
||||
<aside className="quiz-labs" aria-label="Lab values">
|
||||
<div className="quiz-labs-head">
|
||||
<strong>Lab values</strong>
|
||||
<button type="button" aria-label="Close lab values"
|
||||
onClick={() => setLabsOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="quiz-labs-body"><LabValues /></div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
|
||||
<div className="quiz-sidebar quiz-rail">
|
||||
<div className="quiz-rail-head">Session questions</div>
|
||||
<div className="quiz-rail-head">
|
||||
<span>Session questions</span>
|
||||
<button type="button" className="quiz-rail-hide" aria-label="Hide the session questions"
|
||||
onClick={() => setRailOpen(false)}>‹</button>
|
||||
</div>
|
||||
<div className="quiz-rail-list">
|
||||
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -504,7 +504,9 @@ describe('quiz player', () => {
|
|||
})
|
||||
|
||||
it('offers a functional calculator without hijacking input shortcuts', async () => {
|
||||
await begin()
|
||||
// The exam's. A study session has no clock to beat and no arithmetic to
|
||||
// do under one, so the toolbar it sat in is the exam's too.
|
||||
await begin(false)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Calculator/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Calculator' })
|
||||
const input = within(dialog).getByLabelText('Calculation')
|
||||
|
|
@ -859,3 +861,30 @@ describe('quiz player', () => {
|
|||
expect(within(dialog).queryByRole('button', { name: 'Create a share link' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the exam\'s tools out of a study session', async () => {
|
||||
await begin()
|
||||
for (const gone of [/Calculator/, /Lab values/]) {
|
||||
expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument()
|
||||
}
|
||||
// Labs are on the question's own bar instead, where the case is.
|
||||
const bar = screen.getByRole('toolbar', { name: 'Question actions' })
|
||||
expect(within(bar).getByRole('button', { name: /Labs/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts the rail away and brings it back', async () => {
|
||||
await begin()
|
||||
expect(document.querySelector('.quiz-sidebar')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Hide the session questions' }))
|
||||
// A long stem wants the width, and the list is still one press away.
|
||||
expect(document.querySelector('.quiz-layout.is-rail-closed')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show the session questions' }))
|
||||
expect(document.querySelector('.quiz-layout.is-rail-closed')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves three things at the foot: out, back, on', async () => {
|
||||
await begin()
|
||||
const bar = document.querySelector('.quiz-footbar')
|
||||
const names = [...bar.querySelectorAll('button')].map(b => b.textContent.trim())
|
||||
expect(names).toEqual(['Exit session', '← Prev', 'Skip →'])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -524,3 +524,40 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
border: 1px solid var(--correct-bd); border-radius: 9px;
|
||||
}
|
||||
.quiz-folder-new:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* ── The rail as a sidebar, and the labs as its opposite number ───────
|
||||
Both are optional columns; the question takes whatever they leave. */
|
||||
.quiz-player .quiz-layout.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
|
||||
.quiz-player .quiz-layout.is-rail-closed .quiz-sidebar { display: none; }
|
||||
.quiz-player .quiz-layout.has-labs { grid-template-columns: 260px minmax(0, 1fr) minmax(300px, 360px); }
|
||||
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); }
|
||||
|
||||
.quiz-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.quiz-rail-hide, .quiz-rail-reopen {
|
||||
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
|
||||
border-radius: 50%; width: 24px; height: 24px; cursor: pointer; line-height: 1;
|
||||
font-size: 0.9rem; flex-shrink: 0;
|
||||
}
|
||||
.quiz-rail-hide:hover, .quiz-rail-reopen:hover { color: var(--primary); border-color: var(--primary); }
|
||||
/* Sits where the rail was, so putting it back is where you last saw it. */
|
||||
.quiz-rail-reopen { align-self: start; margin-top: 4px; }
|
||||
|
||||
.quiz-labs {
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
border-left: 1px solid var(--border); background: var(--card-bg);
|
||||
}
|
||||
.quiz-labs-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border); flex: none;
|
||||
}
|
||||
.quiz-labs-head button { border: 0; background: none; cursor: pointer; color: var(--text-muted); font-size: 0.95rem; }
|
||||
.quiz-labs-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px; }
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
/* The rail is a drawer down here, so its handle has nothing to reopen. */
|
||||
.quiz-rail-reopen { display: none; }
|
||||
/* No room for a third column; the labs go back to being a dialog. */
|
||||
.quiz-player .quiz-layout.has-labs,
|
||||
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
|
||||
.quiz-labs { display: none; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue