From 04310c8980ac92882a575e90b4c9bd1798b2856d Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 10:34:58 +0200 Subject: [PATCH] feat: drill-down category pickers and a column browser, matching AMBOSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM --- frontend/src/components/CategoryColumns.css | 74 +++++++++++ frontend/src/components/CategoryColumns.jsx | 109 +++++++++++++++ frontend/src/components/CategoryDrilldown.css | 91 +++++++++++++ frontend/src/components/CategoryDrilldown.jsx | 124 ++++++++++++++++++ frontend/src/pages/ArticlesPage.css | 10 ++ frontend/src/pages/ArticlesPage.jsx | 20 ++- frontend/src/pages/CustomQuizPage.jsx | 8 +- frontend/src/pages/QuestionBankPage.jsx | 19 +-- frontend/src/pages/QuestionEditPage.css | 3 +- frontend/src/pages/QuestionEditPage.jsx | 27 +--- 10 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/CategoryColumns.css create mode 100644 frontend/src/components/CategoryColumns.jsx create mode 100644 frontend/src/components/CategoryDrilldown.css create mode 100644 frontend/src/components/CategoryDrilldown.jsx diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css new file mode 100644 index 0000000..d796a3a --- /dev/null +++ b/frontend/src/components/CategoryColumns.css @@ -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; } +} diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx new file mode 100644 index 0000000..b55971b --- /dev/null +++ b/frontend/src/components/CategoryColumns.jsx @@ -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) => ( +
+
+ {level === 0 ? allLabel : byId[column.parentId]?.name} +
+
    + {level === 0 && ( +
  • + +
  • + )} + {column.items.map(cat => { + const kids = childrenOf[cat.id] || [] + const isOpen = path[level] === cat.id + return ( +
  • + +
  • + ) + })} +
+
+ ) + + // Narrow screens show the deepest column only, with a way back up. + const deepest = columns.length - 1 + + return ( +
+ {path.length > 0 && ( + + )} +
+ {columns.map((column, level) => ( +
+ {renderColumn(column, level)} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/CategoryDrilldown.css b/frontend/src/components/CategoryDrilldown.css new file mode 100644 index 0000000..797fc7c --- /dev/null +++ b/frontend/src/components/CategoryDrilldown.css @@ -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; } +} diff --git a/frontend/src/components/CategoryDrilldown.jsx b/frontend/src/components/CategoryDrilldown.jsx new file mode 100644 index 0000000..9e41672 --- /dev/null +++ b/frontend/src/components/CategoryDrilldown.jsx @@ -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

Nothing matches that search.

+ return ( +
+ {hits.slice(0, 80).map(cat => { + const path = trail(cat) + return ( + + ) + })} + {hits.length > 80 && ( +

{hits.length - 80} more — keep typing to narrow.

+ )} +
+ ) + } + + const renderLevel = (parentId, depth) => { + const branch = childrenOf[parentId] || [] + if (!branch.length) return null + return ( + + ) + } + + return renderLevel(0, 0) ||

No categories yet.

+} diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 0131a1d..8495fdb 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -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; } diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 6ef8765..1642d87 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -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() { )}
setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" /> - +
+ + {/* 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 && ( +
+ setCategoryId(id == null ? '' : String(id))} /> +
+ )} {loading ?
: articles.length === 0 ? (
No articles yet. Educators add and refine articles gradually.
) : ( diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index ea4e06b..4de0b27 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -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 ────────────────────────────────────────── */} setOpenFacet(null)} onReset={() => setCategoryIds([])} - helper="By default, all systems are included unless filters are selected. A parent includes its subcategories."> - {query => renderTree(0, query) ||

Nothing matches that search.

} + helper="Open a system to see what is under it. Choosing one includes everything beneath."> + {query => ( + toggleCategory(id, on)} /> + )}
setOpenFacet(null)} diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index d188cb4..2709599 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -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() { 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

Nothing matches that search.

- return shown.map(cat => ( - - )) - }} + helper="Open a system to see what is under it. Choosing one includes everything beneath."> + {query => ( + setFilterCatIds(ids => on ? [...ids, id] : ids.filter(c => c !== id))} /> + )}
setOpenFacet(null)} diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css index e73dd62..87d0963 100644 --- a/frontend/src/pages/QuestionEditPage.css +++ b/frontend/src/pages/QuestionEditPage.css @@ -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; diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx index 3c510a5..a063d26 100644 --- a/frontend/src/pages/QuestionEditPage.jsx +++ b/frontend/src/pages/QuestionEditPage.jsx @@ -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' }) { - Also appears in + Also appears in setCatQuery(e.target.value)} />
- {visibleCategories.map(c => ( - - ))} - {visibleCategories.length === 0 &&

Nothing matches that search.

} + toggleExtra(id)} />
{form.extraCategoryIds.length > 0 && (