feat: take a selection off inside the picker

A facet panel could add a choice but not remove one without finding its checkbox
again in a list of several hundred — and Reset, the only alternative, throws away
nine choices to undo the tenth. What is picked now shows as chips under the
search box, each removable on its own, with Clear all beside them once there is
more than one.

248 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 02:54:17 +02:00
parent e63c625ed4
commit d1388f3335
4 changed files with 83 additions and 2 deletions

View file

@ -24,7 +24,14 @@ export function FacetRow({ label, summary, extra, chip, onOpen }) {
* `children` is called with the current search query so each facet decides how
* to filter its own options.
*/
export default function FacetPicker({ title, helper, open, onClose, onReset, searchLabel, children }) {
export default function FacetPicker({
title, helper, open, onClose, onReset, searchLabel, children,
// What is currently picked, as {id, label}. Shown as chips you can take off
// one at a time: without them, unpicking something means finding its checkbox
// again in a list of several hundred, and Reset is the only alternative
// which throws away the other nine choices to undo the tenth.
selected = [], onRemove,
}) {
const [query, setQuery] = useState('')
useEffect(() => {
@ -50,6 +57,23 @@ export default function FacetPicker({ title, helper, open, onClose, onReset, sea
placeholder="Search" aria-label={searchLabel || `Search ${title}`} />
</div>
{selected.length > 0 && onRemove && (
<div className="facet-panel-chosen">
{selected.map(item => (
<button type="button" key={item.id} className="facet-chosen-chip"
aria-label={`Remove ${item.label}`} onClick={() => onRemove(item.id)}>
<span>{item.label}</span>
<span aria-hidden="true"></span>
</button>
))}
{selected.length > 1 && onReset && (
<button type="button" className="facet-chosen-clear" onClick={onReset}>
Clear all
</button>
)}
</div>
)}
{helper && <p className="facet-panel-helper">{helper}</p>}
<p className="facet-panel-legend">Include questions from:</p>

View file

@ -267,3 +267,28 @@
.custom-test-refresh { flex: 0 0 auto; }
.custom-test-start { flex: 1; width: auto; margin-left: 0; min-width: 0; }
}
/* What you have picked, inside the picker. Taking one off should not mean
hunting its checkbox back down in a list of several hundred, and Reset is no
answer it throws away nine choices to undo the tenth. */
.facet-panel-chosen {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 10px 18px 0;
}
.facet-chosen-chip {
display: inline-flex; align-items: center; gap: 7px;
min-height: 28px; padding: 4px 9px; cursor: pointer;
border: 1px solid var(--option-sel-bd, var(--primary)); border-radius: 999px;
background: var(--option-sel-bg); color: var(--primary);
font: inherit; font-size: 0.78rem; font-weight: 600;
}
.facet-chosen-chip:hover { background: var(--primary); color: #fff; }
.facet-chosen-chip span:first-child {
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.facet-chosen-clear {
min-height: 28px; padding: 4px 9px; cursor: pointer;
background: none; border: 0; font: inherit; font-size: 0.76rem;
color: var(--text-muted); text-decoration: underline;
}
.facet-chosen-clear:hover { color: var(--wrong-fg); }

View file

@ -377,6 +377,8 @@ export default function CustomQuizPage() {
{/* ── Facet pickers ────────────────────────────────────────── */}
<FacetPicker title="Systems" open={openFacet === 'organ-systems'} onClose={() => setOpenFacet(null)}
onReset={() => setTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
selected={systemTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))}
onRemove={id => toggleTag(id, false)}
helper="Body systems. Flat by nature — there are only so many.">
{query => {
const shown = systemTags.filter(t => !query || t.name.toLowerCase().includes(query))
@ -394,6 +396,8 @@ export default function CustomQuizPage() {
<FacetPicker title="Topics" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
onReset={() => setCategoryIds([])}
selected={categories.filter(c => categoryIds.includes(c.id)).map(c => ({ id: c.id, label: c.name }))}
onRemove={id => toggleCategory(id, false)}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (
<CategoryDrilldown categories={categories} selectedIds={categoryIds} query={query}
@ -403,18 +407,24 @@ export default function CustomQuizPage() {
<FacetPicker title="Disciplines" open={openFacet === 'disciplines'} onClose={() => setOpenFacet(null)}
onReset={() => setTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))}
selected={subjectTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))}
onRemove={id => toggleTag(id, false)}
helper="By default, all disciplines are included unless filters are selected.">
{query => checkList(subjectTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)}
</FacetPicker>
<FacetPicker title="Symptoms &amp; keywords" open={openFacet === 'symptoms'} onClose={() => setOpenFacet(null)}
onReset={() => setTagIds(ids => ids.filter(id => !keywordTags.some(t => t.id === id)))}
selected={keywordTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))}
onRemove={id => toggleTag(id, false)}
helper="By default, all keywords are included unless filters are selected.">
{query => checkList(keywordTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)}
</FacetPicker>
<FacetPicker title="Articles" open={openFacet === 'articles'} onClose={() => setOpenFacet(null)}
onReset={() => setArticleIds([])}
selected={articles.filter(a => articleIds.includes(a.id)).map(a => ({ id: a.id, label: a.title }))}
onRemove={id => setArticleIds(ids => ids.filter(x => x !== id))}
helper="Pick the reading whose linked questions you want.">
{query => articles.length === 0
? <p className="facet-panel-empty">No articles yet.</p>

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor , within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -202,3 +202,25 @@ describe('CustomQuizPage', () => {
expect(screen.getByRole('button', { name: 'Create Test' })).toBeDisabled()
})
})
it('shows what you picked inside the picker, and lets you take one off', async () => {
api.get.mockImplementation(url => {
if (url === '/question-categories/') return Promise.resolve({ data: categories })
if (url.startsWith('/tags')) return Promise.resolve({ data: TAGS })
if (url.startsWith('/articles') || url.startsWith('/collections')) return Promise.resolve({ data: [] })
return Promise.resolve({ data: { count: 30 } })
})
renderBuilder()
await userEvent.click(await screen.findByRole('button', { name: /Disciplines/ }))
const panel = screen.getByRole('dialog', { name: 'Disciplines' })
await userEvent.click(within(panel).getByRole('checkbox', { name: /Cardiology/ }))
// The chip is the way back out. Hunting the checkbox down again in a list of
// several hundred is not, and Reset throws away every other choice.
const chip = within(panel).getByRole('button', { name: 'Remove Cardiology' })
await userEvent.click(chip)
expect(within(panel).queryByRole('button', { name: 'Remove Cardiology' })).not.toBeInTheDocument()
expect(within(panel).getByRole('checkbox', { name: /Cardiology/ })).not.toBeChecked()
})