diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx index ea30376..675cf36 100644 --- a/frontend/src/components/QuizTools.jsx +++ b/frontend/src/components/QuizTools.jsx @@ -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([]) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 4380f71..2f192b7 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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 - {/* 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 && ( - - )} + {/* 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 )} - {/* Two-column layout: content + desktop sidebar */} -
+ {/* The question, with the rail on one side and the labs on the other — + both optional, and the question taking whatever they leave. */} +
+ {/* 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 && ( + + )} {/* Main content */}
@@ -1731,8 +1748,14 @@ const timerStarted = timeLeft !== null )}
- - + {/* 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 && ( + <> + + + + )} {/* 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 />
+ {/* 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. */} + {current.attending_tip && ( +
+
+ + )} + {/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
-
Session questions
+
+ Session questions + +
{questions.map((q, i) => )}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 491f476..6da6e1f 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -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 →']) +}) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 9233f33..084ffad 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -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; } +}