diff --git a/frontend/src/components/FacetPicker.jsx b/frontend/src/components/FacetPicker.jsx index 32cffeb..2eb25f0 100644 --- a/frontend/src/components/FacetPicker.jsx +++ b/frontend/src/components/FacetPicker.jsx @@ -1,15 +1,20 @@ import { useEffect, useState } from 'react' -/** Collapsed facet row: label on the left, current selection on the right. */ -export function FacetRow({ label, summary, extra, onOpen }) { +/** + * Collapsed facet row: a + affordance, the label, and the current selection on + * the right — plain "All" when nothing is picked, a chip (plus a +N chip) once + * something is. + */ +export function FacetRow({ label, summary, extra, chip, onOpen }) { + const selected = chip ?? summary !== 'All' return ( ) } diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 11df2c6..18fe683 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -1,71 +1,159 @@ /* Custom test builder — AMBOSS-style facet rows that open a picker panel, instead of a cramped sidebar of nested scrolling checkbox lists. */ -.custom-test { max-width: 720px; margin: 0 auto; padding-bottom: 40px; } -.custom-test-back { font-size: 0.85rem; color: var(--primary); text-decoration: none; } -.custom-test h1 { margin: 10px 0 4px; font-size: 1.5rem; } -.custom-test-intro { margin: 0 0 20px; color: var(--text-muted); font-size: 0.9rem; } +.custom-test { max-width: 1120px; margin: 0 auto; padding-bottom: 100px; } -/* ── Section headings ─────────────────────────────────────────────── */ -.custom-test-section { margin-bottom: 22px; } -.custom-test-section-head { - display: flex; align-items: center; gap: 12px; margin: 0 0 10px; +.custom-test-top { + display: grid; grid-template-columns: 1fr auto; align-items: end; + gap: 6px 16px; margin-bottom: 16px; } -.custom-test-section-head h2 { margin: 0; font-size: 1rem; font-weight: 650; white-space: nowrap; } -.custom-test-section-head::after { content: ''; flex: 1; height: 1px; background: var(--border); } +.custom-test-back { grid-column: 1 / -1; font-size: 0.85rem; color: var(--primary); text-decoration: none; } +.custom-test-top h1 { margin: 8px 0 4px; font-size: 1.5rem; } +.custom-test-intro { margin: 0; color: var(--text-muted); font-size: 0.9rem; } .custom-test-reset { - background: none; border: none; padding: 0; cursor: pointer; flex-shrink: 0; + display: inline-flex; align-items: center; gap: 6px; align-self: end; + background: none; border: none; padding: 4px; cursor: pointer; font: inherit; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.06em; - text-transform: uppercase; color: var(--primary); + text-transform: uppercase; color: var(--text-muted); white-space: nowrap; } +.custom-test-reset:hover { color: var(--primary); } + +/* ── Two cards side by side ───────────────────────────────────────── */ +.custom-test-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; } +.custom-test-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: 12px; overflow: hidden; +} +.custom-test-card > h2 { + margin: 0; padding: 16px 18px; font-size: 1.05rem; font-weight: 650; + border-bottom: 1px solid var(--border); +} +.custom-test-card-pad { padding: 14px 18px; } .custom-test-label { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin: 0 0 6px; } /* ── Global facet search ──────────────────────────────────────────── */ -.custom-test-search { position: relative; margin-bottom: 12px; } +.custom-test-search { position: relative; } .custom-test-search input { width: 100%; padding: 10px 13px 10px 34px; font-size: 0.9rem; border: 1px solid var(--border); border-radius: 8px; - background: var(--input-bg); color: var(--text); + background: var(--bg); color: var(--text); } .custom-test-search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-subtle); } -.custom-test-hits { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 12px; } +.custom-test-hits { border-top: 1px solid var(--border); } .custom-test-hits label { - display: flex; align-items: center; gap: 10px; padding: 10px 13px; + display: flex; align-items: center; gap: 10px; padding: 10px 18px; font-size: 0.88rem; cursor: pointer; border-bottom: 1px solid var(--border); } -.custom-test-hits label:last-child { border-bottom: 0; } .custom-test-hits label:hover { background: var(--bg); } .custom-test-hit-facet { margin-left: auto; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); } -.custom-test-hits-empty { padding: 14px; color: var(--text-muted); font-size: 0.86rem; } +.custom-test-hits-empty { padding: 12px 18px; color: var(--text-muted); font-size: 0.86rem; margin: 0; border-top: 1px solid var(--border); } /* ── Facet rows ───────────────────────────────────────────────────── */ -.facet-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--card-bg); } +.facet-list { border-top: 1px solid var(--border); } .facet-row { display: flex; align-items: center; gap: 12px; width: 100%; - padding: 13px 15px; background: none; border: none; border-bottom: 1px solid var(--border); + padding: 14px 18px; background: none; border: none; border-bottom: 1px solid var(--border); font: inherit; text-align: left; cursor: pointer; color: var(--text); } .facet-row:last-child { border-bottom: 0; } .facet-row:hover { background: var(--bg); } -.facet-row-label { font-size: 0.9rem; font-weight: 600; white-space: nowrap; } +.facet-row-icon { color: var(--text-subtle); font-size: 1.15rem; line-height: 1; flex-shrink: 0; width: 14px; } +.facet-row:hover .facet-row-icon { color: var(--primary); } +.facet-row-label { font-size: 0.95rem; } .facet-row-summary { margin-left: auto; display: flex; align-items: center; gap: 6px; min-width: 0; } -.facet-row-value { - font-size: 0.86rem; color: var(--text-muted); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46vw; +.facet-row-value { font-size: 0.9rem; color: var(--text-muted); white-space: nowrap; } +.facet-chip { + font-size: 0.82rem; border-radius: 6px; padding: 3px 9px; + background: var(--option-sel-bg); color: var(--primary); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; max-width: 190px; } -.facet-row-more { - flex-shrink: 0; font-size: 0.72rem; font-weight: 700; - background: var(--primary); color: var(--primary-fg); border-radius: 20px; padding: 1px 8px; -} -.facet-row-chevron { color: var(--text-subtle); font-size: 1.1rem; line-height: 1; flex-shrink: 0; } +.facet-chip.is-more { flex-shrink: 0; font-weight: 700; } .facet-row.is-fixed { cursor: default; } .facet-row.is-fixed:hover { background: none; } +.facet-row.is-fixed .facet-row-icon { color: var(--text-subtle); } + +/* ── Criteria ─────────────────────────────────────────────────────── */ +.custom-test-field { display: block; } +.custom-test-field > span { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin-bottom: 6px; } +.custom-test-field input { + width: 100%; padding: 10px 13px; font-size: 0.95rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.custom-test-adaptive { + display: flex; align-items: center; gap: 12px; + padding: 14px 18px; border-top: 1px solid var(--border); +} +.custom-test-adaptive > div { flex: 1; min-width: 0; } +.custom-test-adaptive strong { display: block; font-size: 0.98rem; font-weight: 650; color: #6d28d9; } +.custom-test-adaptive p { margin: 2px 0 0; font-size: 0.82rem; color: var(--text-muted); } +.custom-test-spark { margin-right: 4px; } +.custom-test-toggle { display: inline-flex; align-items: center; cursor: pointer; flex-shrink: 0; } +.custom-test-toggle input { position: absolute; opacity: 0; width: 0; height: 0; } +.custom-test-switch { + width: 40px; height: 22px; border-radius: 999px; background: var(--border); + position: relative; flex-shrink: 0; transition: background 0.15s; +} +.custom-test-switch > span { + position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; + border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.15s; +} +.custom-test-toggle input:checked + .custom-test-switch { background: #7c3aed; } +.custom-test-toggle input:checked + .custom-test-switch > span { transform: translateX(18px); } +.custom-test-toggle input:focus-visible + .custom-test-switch { outline: 2px solid var(--primary); outline-offset: 2px; } + +.custom-test-more { + display: flex; align-items: center; gap: 6px; width: 100%; + padding: 12px 18px; background: none; border: none; border-top: 1px solid var(--border); + font: inherit; font-size: 0.76rem; font-weight: 700; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--text-muted); cursor: pointer; +} +.custom-test-more:hover { color: var(--primary); } +.custom-test-check { display: flex; align-items: center; gap: 9px; font-size: 0.88rem; cursor: pointer; } + +.custom-test-countbox { padding: 14px 18px; border-top: 1px solid var(--border); } +.custom-test-count-row { display: flex; align-items: center; gap: 10px; } +.custom-test-count-row input { + width: 92px; padding: 10px 12px; font-size: 1rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.custom-test-count-of { color: var(--text-muted); font-size: 1rem; } +.custom-test-count-note { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-subtle); } +.custom-test-note { color: var(--text-muted); font-size: 0.8rem; line-height: 1.6; margin: 8px 0 0; } +.custom-test-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 8px 0 0; } + +/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ── */ +.custom-test-bar { + grid-column: 1 / -1; + position: sticky; bottom: 0; z-index: 40; + margin: 4px -16px -100px; padding: 0 16px; + background: var(--card-bg); border-top: 1px solid var(--border); + box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06); +} +.custom-test-bar-inner { + max-width: 1120px; margin: 0 auto; + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 12px 0 calc(12px + env(safe-area-inset-bottom)); +} +.custom-test-bar-label { margin: 0; } +.custom-test-modes { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.custom-test-modes label { cursor: pointer; } +.custom-test-modes input { position: absolute; opacity: 0; width: 0; height: 0; } +.custom-test-modes span { + display: block; padding: 9px 18px; font-size: 0.9rem; font-weight: 600; + color: var(--text-muted); background: var(--card-bg); white-space: nowrap; +} +.custom-test-modes label + label span { border-left: 1px solid var(--border); } +.custom-test-modes input:checked + span { background: var(--option-sel-bg); color: var(--primary); } +.custom-test-modes input:focus-visible + span { outline: 2px solid var(--primary); outline-offset: -2px; } +.custom-test-start { margin-left: auto; min-width: 150px; } /* ── Picker panel ─────────────────────────────────────────────────── */ .facet-overlay { @@ -153,10 +241,20 @@ .custom-test-actions { display: flex; gap: 8px; margin-top: 18px; } .custom-test-actions .btn:last-child { flex: 1; } +@media (max-width: 900px) { + .custom-test-grid { grid-template-columns: 1fr; } +} @media (max-width: 640px) { - .custom-test-modes label { min-width: 100%; } + .custom-test { padding-bottom: 120px; } + .custom-test-top { grid-template-columns: 1fr; } + .custom-test-reset { justify-self: start; padding-left: 0; } .facet-overlay { align-items: flex-end; } .facet-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; } - .facet-row-value { max-width: 40vw; } - .custom-test-actions { flex-direction: column; } + .facet-chip { max-width: 40vw; } + .custom-test-bar-label { display: none; } + .custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); } + .custom-test-modes { flex: 1; } + .custom-test-modes label { flex: 1; } + .custom-test-modes span { text-align: center; padding: 9px 10px; } + .custom-test-start { width: 100%; margin-left: 0; } } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index ca4fc74..d5a65c2 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -49,6 +49,7 @@ export default function CustomQuizPage() { const [collections, setCollections] = useState([]) const [openFacet, setOpenFacet] = useState(null) const [globalSearch, setGlobalSearch] = useState('') + const [moreOpen, setMoreOpen] = useState(false) const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, refresh]) @@ -211,28 +212,34 @@ export default function CustomQuizPage() { return (
- ← Quizzes -

Create Custom Test

-

Choose questions from your bank, {user?.name || 'learner'}.

+
+ ← Quizzes +
+

Create Custom Test

+

Choose questions from your bank, {user?.name || 'learner'}.

+
+ +
-
- {/* ── Topics ─────────────────────────────────────────────── */} -
-
-

Set test topics

- -
+ + {/* ── Topics ───────────────────────────────────────────────── */} +
+

Set test topics

- Filter search -
- - setGlobalSearch(e.target.value)} - placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" /> +
+ Filter search +
+ + setGlobalSearch(e.target.value)} + placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" /> +
{hits !== null && ( hits.length === 0 - ?

Nothing matches “{globalSearch}”.

+ ?

Nothing matches “{globalSearch}”.

: (
{hits.map(hit => ( @@ -248,100 +255,119 @@ export default function CustomQuizPage() {
+ Exams - Pediatrics Boards + Pediatrics Boards
setOpenFacet('systems')} /> setOpenFacet('disciplines')} /> - setOpenFacet('symptoms')} /> + setOpenFacet('symptoms')} /> setOpenFacet('articles')} /> - setOpenFacet('saved')} /> + setOpenFacet('saved')} />
- {/* ── Criteria ───────────────────────────────────────────── */} -
-

Test criteria

+ {/* ── Criteria ─────────────────────────────────────────────── */} +
+

Test criteria

- +
+ +
- +
+
+ Adaptive session +

Questions prioritized by impact

+
+ +
{adaptive && ( -

- Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older - incorrect ones, and moves between weak areas instead of repeating one. +

+ Adaptive prefers unanswered questions, then recycles older incorrect ones, and moves between weak + areas instead of repeating one.

)} -
- setOpenFacet('difficulty')} /> - setOpenFacet('status')} /> +
+ setOpenFacet('difficulty')} /> + setOpenFacet('status')} />
- -

- Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, - including skipped questions. -

-
- - {/* ── Count and type ─────────────────────────────────────── */} -
- Question count -
- setCount(e.target.value)} /> - - {ready ? `${available} questions available` : 'Counting available questions…'} - -
- {ready && available === 0 &&

No questions match these filters.

} - {ready && !validCount && available > 0 &&

Choose 1–{Math.min(200, available)} questions.

} - {countError &&

{countError}

} -
- -
- Test type -
- - -
- {mode === 'timed' && ( - - )} -
- -
- - + {moreOpen && ( +
+ + {mode === 'timed' && ( + + )} +

+ Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, + including skipped questions. +

+
+ )} + +
+ Question count +
+ setCount(e.target.value)} /> + + {ready ? `/${available}` : 'Counting…'} + +
+

+ {ready ? `${available} questions available` : 'Counting available questions…'} +

+ {ready && available === 0 &&

No questions match these filters.

} + {ready && !validCount && available > 0 &&

Choose 1–{Math.min(200, available)} questions.

} + {countError &&

{countError}

} +
+
+ + {/* ── Sticky action bar ────────────────────────────────────── */} +
+
+ Test type +
+ + +
+ + +
{error &&

{error}

} - {/* ── Facet pickers ────────────────────────────────────────── */} setOpenFacet(null)} onReset={() => setCategoryIds([])} diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index 6723fe8..c493dc6 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -35,6 +35,9 @@ describe('CustomQuizPage', () => { renderBuilder() await screen.findByText('30 questions available') expect(screen.getByRole('radio', { name: 'Study mode' })).toBeChecked() + // Sharing is our own addition to AMBOSS's criteria, so it lives under More. + expect(screen.queryByLabelText(/Share with/)).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /^More/ })) expect(screen.getByLabelText(/Share with/)).not.toBeChecked() expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument() @@ -157,6 +160,32 @@ describe('CustomQuizPage', () => { expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All') }) + it('keeps the count in the criteria card and mode plus Start in a sticky bar', async () => { + renderBuilder() + await screen.findByText('30 questions available') + + // Question count sits inside the criteria card, shown as "20 /30". + const countInput = screen.getByLabelText('Number of questions') + expect(countInput.closest('.custom-test-card')).toContainElement(screen.getByRole('heading', { name: 'Test criteria' })) + expect(screen.getByText('/30')).toBeInTheDocument() + + // Mode and the submit button live in the sticky bar, not in the cards. + const bar = document.querySelector('.custom-test-bar') + expect(bar).toBeInTheDocument() + expect(bar).toContainElement(screen.getByRole('radio', { name: 'Study mode' })) + expect(bar).toContainElement(screen.getByRole('radio', { name: 'Exam mode' })) + expect(bar).toContainElement(screen.getByRole('button', { name: 'Create Test' })) + }) + + it('reveals the time limit under More once exam mode is picked', async () => { + renderBuilder() + await screen.findByText('30 questions available') + await userEvent.click(screen.getByRole('button', { name: /^More/ })) + expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('radio', { name: 'Exam mode' })) + expect(screen.getByLabelText(/Time limit/)).toBeInTheDocument() + }) + it('ignores outdated count responses after filters change', async () => { let resolveOld api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: categories }) : new Promise(resolve => { resolveOld = resolve }))