feat: drill-down category pickers and a column browser, matching AMBOSS

The tree was nested in the database but every picker still rendered it flat as
breadcrumb strings — "Neurology › Seizures & Epilepsy › Absence Seizure" beside
"Genetics & Metabolism › Achondroplasia" — so the nesting was invisible and 780
rows competed for attention at once.

CategoryDrilldown (facet pickers, question editor)
Walks the tree one level at a time, matching how AMBOSS draws Disciplines and
Symptoms: chevron on the left, then the checkbox, then the name. Nothing is
expanded on load; expanding is a separate control from selecting, so opening a
branch never silently applies a filter; and one branch stays open per level, so
the list cannot grow back into the wall it replaced. Typing flattens to matching
rows with their trail, because when you type a name you want the row, not the
path to it. Counts are roll-ups, so the number matches what the filter returns.

CategoryColumns (articles page)
Replaces a 780-entry dropdown with side-by-side columns, as in the AMBOSS
library: choosing in one column opens its children in the next, so the trail you
took stays on screen. Under 720px it becomes one column plus a back button —
the same navigation drawn for the width available, rather than two columns
squeezed until neither is readable.

Touch targets are at least 44px throughout, and expand and select are separate
hit areas so neither is a near-miss for the other on a phone.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
This commit is contained in:
Daniel 2026-09-10 10:34:58 +02:00
parent d071e5cdc5
commit 04310c8980
10 changed files with 443 additions and 42 deletions

View file

@ -0,0 +1,74 @@
/* Side-by-side category columns, as in a library browser.
Under 720px there is no room for two, so it becomes one column plus a back
button the same navigation drawn for the width available. */
.cc-wrap { display: flex; flex-direction: column; gap: 8px; }
.cc-back {
align-self: flex-start;
display: none;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 8px 12px;
background: none;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
font-size: 0.86rem;
color: var(--primary);
cursor: pointer;
}
.cc-columns { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; }
.cc-column-slot { flex: 0 0 clamp(220px, 32%, 320px); min-width: 0; }
.cc-column {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.cc-column-head {
padding: 11px 14px;
border-bottom: 1px solid var(--border);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-subtle);
overflow-wrap: anywhere;
}
.cc-column ul { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow-y: auto; }
.cc-column li + li .cc-row { border-top: 1px solid var(--border); }
.cc-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 10px 14px;
background: none;
border: none;
font: inherit;
text-align: left;
color: var(--text);
cursor: pointer;
}
.cc-row:hover { background: var(--bg); }
.cc-row.is-open { background: var(--option-sel-bg); }
.cc-row.is-selected { font-weight: 650; color: var(--primary); }
.cc-name { flex: 1; min-width: 0; font-size: 0.89rem; line-height: 1.35; overflow-wrap: anywhere; }
.cc-count { flex-shrink: 0; font-size: 0.76rem; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
.cc-chevron { flex-shrink: 0; color: var(--text-subtle); font-size: 1.1rem; line-height: 1; }
@media (max-width: 720px) {
.cc-back { display: inline-flex; }
/* Only the column you are looking at; the trail is the back button. */
.cc-column-slot { display: none; flex: 1 1 100%; }
.cc-column-slot.is-deepest { display: block; }
.cc-columns { overflow-x: visible; }
.cc-column ul { max-height: none; }
}

View file

@ -0,0 +1,109 @@
import { useMemo, useState } from 'react'
import './CategoryColumns.css'
/**
* Browse the category tree as side-by-side columns.
*
* Picking a row in one column opens its children in the next, so the trail you
* took stays on screen you can see where you are and step back a level without
* losing your place. A single dropdown of 780 names shows none of that.
*
* On a phone there is no room for two columns, so it becomes one column with a
* back button: the same navigation, drawn for the width available, rather than
* two columns squeezed until neither is readable.
*/
export default function CategoryColumns({ categories, selectedId, onSelect, allLabel = 'All categories' }) {
// The chain of opened parents, root first.
const [path, setPath] = useState([])
const childrenOf = useMemo(() => {
const map = {}
for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
return map
}, [categories])
const rollup = useMemo(() => {
const totals = {}
const walk = (cat) => {
let sum = cat.question_count || 0
for (const child of childrenOf[cat.id] || []) sum += walk(child)
totals[cat.id] = sum
return sum
}
for (const root of childrenOf[0] || []) walk(root)
return totals
}, [childrenOf])
const byId = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c])), [categories])
// One column per opened level: the roots, then each opened parent's children.
const columns = [{ parentId: 0, items: childrenOf[0] || [] }]
for (const id of path) {
const kids = childrenOf[id] || []
if (!kids.length) break
columns.push({ parentId: id, items: kids })
}
const openAt = (level, cat) => {
const next = [...path.slice(0, level), cat.id]
setPath(childrenOf[cat.id]?.length ? next : path.slice(0, level))
onSelect(cat.id)
}
const renderColumn = (column, level) => (
<div className="cc-column" key={`${column.parentId}-${level}`}>
<div className="cc-column-head">
{level === 0 ? allLabel : byId[column.parentId]?.name}
</div>
<ul>
{level === 0 && (
<li>
<button type="button" className={`cc-row${selectedId == null ? ' is-selected' : ''}`}
onClick={() => { setPath([]); onSelect(null) }}>
<span className="cc-name">{allLabel}</span>
</button>
</li>
)}
{column.items.map(cat => {
const kids = childrenOf[cat.id] || []
const isOpen = path[level] === cat.id
return (
<li key={cat.id}>
<button type="button"
className={`cc-row${isOpen ? ' is-open' : ''}${selectedId === cat.id ? ' is-selected' : ''}`}
aria-current={selectedId === cat.id ? 'true' : undefined}
onClick={() => openAt(level, cat)}>
<span className="cc-name">{cat.name}</span>
<span className="cc-count">{rollup[cat.id] ?? cat.question_count}</span>
{kids.length > 0 && <span className="cc-chevron" aria-hidden="true"></span>}
</button>
</li>
)
})}
</ul>
</div>
)
// Narrow screens show the deepest column only, with a way back up.
const deepest = columns.length - 1
return (
<div className="cc-wrap">
{path.length > 0 && (
<button type="button" className="cc-back"
onClick={() => setPath(path.slice(0, -1))}>
{byId[path[path.length - 2]]?.name || allLabel}
</button>
)}
<div className="cc-columns" data-deepest={deepest}>
{columns.map((column, level) => (
<div key={`wrap-${column.parentId}-${level}`}
className={`cc-column-slot${level === deepest ? ' is-deepest' : ''}`}>
{renderColumn(column, level)}
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,91 @@
/* Drill-down category picker.
Touch targets stay at least 44px tall, and the expand control is separate from
the checkbox so neither is a near-miss for the other on a phone. */
.cd-level { list-style: none; margin: 0; padding: 0; }
.cd-level.cd-depth-1,
.cd-level.cd-depth-2 {
margin-left: 10px;
padding-left: 10px;
border-left: 2px solid var(--border);
}
.cd-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 4px 2px;
border-radius: 8px;
}
.cd-row:hover { background: var(--bg); }
.cd-row.is-open { background: var(--option-sel-bg); }
.cd-row > input[type='checkbox'] {
width: 18px;
height: 18px;
flex-shrink: 0;
margin: 0;
cursor: pointer;
}
.cd-label {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
cursor: pointer;
padding: 6px 0;
}
.cd-name { font-size: 0.9rem; line-height: 1.35; overflow-wrap: anywhere; }
.cd-trail { font-size: 0.73rem; color: var(--text-subtle); overflow-wrap: anywhere; }
.cd-count {
flex-shrink: 0;
font-size: 0.78rem;
color: var(--text-subtle);
font-variant-numeric: tabular-nums;
min-width: 30px;
text-align: right;
}
/* Expanding is a separate control from selecting: opening a branch must never
silently apply a filter. */
/* Left of the checkbox, so the eye reads: open this / choose this / what it is. */
.cd-expand {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
min-height: 44px;
background: none;
border: 1px solid transparent;
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
font: inherit;
}
.cd-expand:hover { background: var(--card-bg); border-color: var(--border); color: var(--primary); }
.cd-expand:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.cd-chevron {
display: inline-block;
font-size: 1.15rem;
line-height: 1;
transition: transform 0.15s ease;
}
.cd-expand[aria-expanded='true'] .cd-chevron { transform: rotate(90deg); }
.cd-expand.is-leaf { pointer-events: none; }
.cd-results { display: flex; flex-direction: column; }
@media (max-width: 640px) {
/* Indentation costs width that long condition names need. */
.cd-level.cd-depth-1,
.cd-level.cd-depth-2 { margin-left: 5px; padding-left: 7px; }
.cd-name { font-size: 0.92rem; }
.cd-count { min-width: 26px; }
.cd-expand { width: 28px; }
}

View file

@ -0,0 +1,124 @@
import { useMemo, useState } from 'react'
import './CategoryDrilldown.css'
/**
* Pick categories by walking the tree, one level at a time.
*
* The tree is three deep and about 780 rows, so a flat list of breadcrumb
* strings ("Neurology Seizures & Epilepsy Absence Seizure") is unreadable
* you cannot see the shape, and everything competes for attention at once.
*
* Rules, deliberately:
* * nothing is expanded on load;
* * expanding is a separate control from selecting, so opening a branch never
* silently filters;
* * one branch open per level opening a sibling closes the previous one, so
* the list never grows into the same wall it replaced;
* * searching flattens to matches with their trail, because when you type a
* name you want the row, not the path to it.
*
* Selecting a parent means "and everything beneath it"; the count shown is the
* roll-up, so the number matches what the filter will actually return.
*/
export default function CategoryDrilldown({ categories, selectedIds, onToggle, query = '' }) {
// One open branch per parent, keyed by that parent's id.
const [openAt, setOpenAt] = useState({})
const childrenOf = useMemo(() => {
const map = {}
for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
return map
}, [categories])
const rollup = useMemo(() => {
const totals = {}
const walk = (cat) => {
let sum = cat.question_count || 0
for (const child of childrenOf[cat.id] || []) sum += walk(child)
totals[cat.id] = sum
return sum
}
for (const root of childrenOf[0] || []) walk(root)
return totals
}, [childrenOf])
const trail = (cat) => {
const parts = []
let cursor = cat
let guard = 0
while (cursor && guard++ < 6) {
parts.unshift(cursor.name)
cursor = categories.find(c => c.id === cursor.parent_id)
}
return parts
}
// Typing wants the matching row, not the branch it lives in.
if (query) {
const hits = categories.filter(c => c.name.toLowerCase().includes(query))
if (!hits.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
return (
<div className="cd-results">
{hits.slice(0, 80).map(cat => {
const path = trail(cat)
return (
<label key={cat.id} className="cd-row">
<input type="checkbox" checked={selectedIds.includes(cat.id)}
onChange={e => onToggle(cat.id, e.target.checked)} />
<span className="cd-label">
<span className="cd-name">{cat.name}</span>
{path.length > 1 && <span className="cd-trail">{path.slice(0, -1).join(' ')}</span>}
</span>
<span className="cd-count">{rollup[cat.id] ?? cat.question_count}</span>
</label>
)
})}
{hits.length > 80 && (
<p className="facet-panel-empty">{hits.length - 80} more keep typing to narrow.</p>
)}
</div>
)
}
const renderLevel = (parentId, depth) => {
const branch = childrenOf[parentId] || []
if (!branch.length) return null
return (
<ul className={`cd-level cd-depth-${depth}`}>
{branch.map(cat => {
const children = childrenOf[cat.id] || []
const isOpen = openAt[parentId] === cat.id
return (
<li key={cat.id}>
<div className={`cd-row${isOpen ? ' is-open' : ''}`}>
{children.length > 0 ? (
<button type="button" className="cd-expand"
aria-expanded={isOpen}
aria-label={`${isOpen ? 'Collapse' : 'Expand'} ${cat.name}`}
onClick={() => setOpenAt(prev => ({
...prev,
// Toggling closes a sibling, keeping one branch open per level.
[parentId]: prev[parentId] === cat.id ? null : cat.id,
}))}>
<span className="cd-chevron" aria-hidden="true"></span>
</button>
) : <span className="cd-expand is-leaf" aria-hidden="true" />}
<input type="checkbox" id={`cd-${cat.id}`}
checked={selectedIds.includes(cat.id)}
onChange={e => onToggle(cat.id, e.target.checked)} />
<label className="cd-label" htmlFor={`cd-${cat.id}`}>
<span className="cd-name">{cat.name}</span>
</label>
<span className="cd-count">{rollup[cat.id] ?? cat.question_count}</span>
</div>
{isOpen && renderLevel(cat.id, depth + 1)}
</li>
)
})}
</ul>
)
}
return renderLevel(0, 0) || <p className="facet-panel-empty">No categories yet.</p>
}

View file

@ -137,3 +137,13 @@
.article-section { margin-top: 20px; }
.article-section > h2 { font-size: 1.06rem; }
}
/* Category browser on the articles page */
.articles-browse {
display: inline-flex; align-items: center; gap: 8px; min-height: 42px;
padding: 9px 14px; background: var(--card-bg); border: 1px solid var(--border);
border-radius: 8px; font: inherit; font-size: .88rem; color: var(--text); cursor: pointer;
}
.articles-browse:hover { border-color: var(--primary); color: var(--primary); }
.articles-browse[aria-expanded='true'] { border-color: var(--primary); color: var(--primary); }
.articles-browser { margin-bottom: 16px; }

View file

@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import RichEditor from '../components/RichEditor'
import CategoryColumns from '../components/CategoryColumns'
import CommentSection from '../components/CommentSection'
import PractiseTopic from '../components/PractiseTopic'
import { markdownImageUrl } from '../utils/uploads'
@ -34,6 +35,7 @@ export default function ArticlesPage() {
const [articles, setArticles] = useState([])
const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState('')
const [browseOpen, setBrowseOpen] = useState(false)
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
@ -44,6 +46,7 @@ export default function ArticlesPage() {
const [aiTopic, setAiTopic] = useState('')
const [aiInstructions, setAiInstructions] = useState('')
const [aiStatus, setAiStatus] = useState('')
const categoryName = categories.find(c => String(c.id) === String(categoryId))?.name
const navigate = useNavigate()
const load = useCallback(() => {
@ -131,11 +134,20 @@ export default function ArticlesPage() {
)}
<div className="articles-toolbar">
<input className="input" value={query} onChange={e => setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" />
<select className="input" value={categoryId} onChange={e => setCategoryId(e.target.value)} aria-label="Filter by category">
<option value="">All categories</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button type="button" className="articles-browse" aria-expanded={browseOpen}
onClick={() => setBrowseOpen(v => !v)}>
{categoryName || 'All categories'} <span aria-hidden="true">{browseOpen ? '▲' : '▼'}</span>
</button>
</div>
{/* Columns rather than one long dropdown: the trail you took stays on
screen, so you can see where you are and step back a level. */}
{browseOpen && (
<div className="articles-browser">
<CategoryColumns categories={categories} selectedId={categoryId === '' ? null : Number(categoryId)}
onSelect={id => setCategoryId(id == null ? '' : String(id))} />
</div>
)}
{loading ? <div className="loading"><div className="spinner" /></div> : articles.length === 0 ? (
<div className="card empty-state">No articles yet. Educators add and refine articles gradually.</div>
) : (

View file

@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import FacetPicker, { FacetRow } from '../components/FacetPicker'
import CategoryDrilldown from '../components/CategoryDrilldown'
import './CustomQuizPage.css'
const STATE_LABEL = { all: 'All', unused: 'Unused', incorrect: 'Incorrect', bookmarked: 'Saved' }
@ -373,8 +374,11 @@ export default function CustomQuizPage() {
{/* ── Facet pickers ────────────────────────────────────────── */}
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
onReset={() => setCategoryIds([])}
helper="By default, all systems are included unless filters are selected. A parent includes its subcategories.">
{query => renderTree(0, query) || <p className="facet-panel-empty">Nothing matches that search.</p>}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (
<CategoryDrilldown categories={categories} selectedIds={categoryIds} query={query}
onToggle={(id, on) => toggleCategory(id, on)} />
)}
</FacetPicker>
<FacetPicker title="Disciplines" open={openFacet === 'disciplines'} onClose={() => setOpenFacet(null)}

View file

@ -5,6 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog'
import CategoryTree from '../components/CategoryTree'
import FacetPicker, { FacetRow } from '../components/FacetPicker'
import CategoryDrilldown from '../components/CategoryDrilldown'
import './CustomQuizPage.css' // facet row + picker panel styles
import './QuestionBankPage.css'
import { useDialog } from '../hooks/useDialog'
@ -908,19 +909,11 @@ export default function QuestionBankPage() {
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
onReset={() => setFilterCatIds([])}
helper="By default all systems are included unless filters are selected.">
{query => {
const shown = categories.filter(c => !query || c.name.toLowerCase().includes(query))
if (!shown.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
return shown.map(cat => (
<label key={cat.id}>
<input type="checkbox" checked={filterCatIds.includes(cat.id)}
onChange={e => setFilterCatIds(ids => e.target.checked ? [...ids, cat.id] : ids.filter(id => id !== cat.id))} />
{(cat.breadcrumbs || []).map(c => c.name).join(' ') || cat.name}
<span className="facet-panel-count">{cat.question_count}</span>
</label>
))
}}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (
<CategoryDrilldown categories={categories} selectedIds={filterCatIds} query={query}
onToggle={(id, on) => setFilterCatIds(ids => on ? [...ids, id] : ids.filter(c => c !== id))} />
)}
</FacetPicker>
<FacetPicker title="Disciplines" open={openFacet === 'disciplines'} onClose={() => setOpenFacet(null)}

View file

@ -57,7 +57,8 @@
width: 100%; padding: 8px 11px; font-size: 0.86rem; margin-bottom: 8px;
border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text);
}
.qe-cat-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; }
.qe-cat-heading { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin: 4px 0 6px; }
.qe-cat-list { max-height: 300px; overflow-y: auto; }
.qe-cat-list label {
display: flex; align-items: center; gap: 9px; padding: 7px 4px;
font-size: 0.86rem; cursor: pointer; border-radius: 6px;

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
import CategoryDrilldown from '../components/CategoryDrilldown'
import './QuestionEditPage.css'
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
@ -96,12 +97,6 @@ export default function QuestionEditPage({ mode = 'edit' }) {
}
})
const visibleCategories = useMemo(() => {
const needle = catQuery.trim().toLowerCase()
if (!needle) return categories
return categories.filter(c =>
[c.name, ...(c.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(needle))
}, [categories, catQuery])
const nameOf = (categoryId) => categories.find(c => c.id === categoryId)?.name
@ -286,25 +281,13 @@ export default function QuestionEditPage({ mode = 'edit' }) {
</select>
</label>
<span className="qe-field" style={{ marginBottom: 6 }}><span>Also appears in</span></span>
<span className="qe-cat-heading">Also appears in</span>
<input className="qe-cat-search" value={catQuery} placeholder="Search categories…"
aria-label="Search categories" onChange={e => setCatQuery(e.target.value)} />
<div className="qe-cat-list">
{visibleCategories.map(c => (
<label key={c.id}>
<input type="checkbox" checked={form.extraCategoryIds.includes(c.id)}
disabled={c.id === Number(form.question_category_id)}
onChange={() => toggleExtra(c.id)} />
<span>
{c.name}
{(c.breadcrumbs || []).length > 1 && (
<span className="qe-cat-crumb"> · {(c.breadcrumbs || []).slice(0, -1).map(b => b.name).join(' ')}</span>
)}
</span>
<span className="qe-cat-count">{c.question_count}</span>
</label>
))}
{visibleCategories.length === 0 && <p className="qe-primary-note">Nothing matches that search.</p>}
<CategoryDrilldown categories={categories} selectedIds={form.extraCategoryIds}
query={catQuery.trim().toLowerCase()}
onToggle={(id) => toggleExtra(id)} />
</div>
{form.extraCategoryIds.length > 0 && (