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:
parent
e63c625ed4
commit
d1388f3335
4 changed files with 83 additions and 2 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue