98 lines
3.4 KiB
React
98 lines
3.4 KiB
React
// FacetGroups.jsx — §22.4a SLICE-3 faceted left-pane filters (§5.1).
|
|
//
|
|
// Renders one collapsible group per facet field returned by the API, each with
|
|
// per-value result counts and multi-select checkboxes. A `tags`-type field gets
|
|
// a "filter values…" search box so it stays usable at 30+ values. Selection
|
|
// state + the malformed toggle are owned by the parent (Catalog), which
|
|
// re-fetches server-side on change.
|
|
|
|
import { useState } from 'react'
|
|
|
|
// Human label for the built-in state facet; declared fields use their own name
|
|
// (or the schema `label` when the API supplies it on `fields`).
|
|
function groupLabel(field, fields) {
|
|
if (field === 'state') return 'State'
|
|
const def = fields?.[field]
|
|
return def?.label || field.charAt(0).toUpperCase() + field.slice(1)
|
|
}
|
|
|
|
function FacetGroup({ field, fields, counts, selected, onToggle, isTags }) {
|
|
const [open, setOpen] = useState(true)
|
|
const [valueSearch, setValueSearch] = useState('')
|
|
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1])
|
|
const needle = valueSearch.trim().toLowerCase()
|
|
const shown = isTags && needle
|
|
? entries.filter(([v]) => v.toLowerCase().includes(needle))
|
|
: entries
|
|
return (
|
|
<div className="facet-group">
|
|
<button className="facet-header" onClick={() => setOpen(o => !o)}>
|
|
<span>{open ? '▾' : '▸'} {groupLabel(field, fields)}</span>
|
|
</button>
|
|
{open && (
|
|
<div className="facet-values">
|
|
{isTags && entries.length > 8 && (
|
|
<input
|
|
className="facet-value-search"
|
|
placeholder="filter values…"
|
|
value={valueSearch}
|
|
onChange={e => setValueSearch(e.target.value)}
|
|
/>
|
|
)}
|
|
{shown.length === 0 && (
|
|
<div className="facet-empty">No values.</div>
|
|
)}
|
|
{shown.map(([value, count]) => (
|
|
<label key={value} className="facet-value">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(value)}
|
|
onChange={() => onToggle(field, value)}
|
|
/>
|
|
<span className="facet-value-label">{value}</span>
|
|
<span className="facet-count">{count}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function FacetGroups({
|
|
facets, fields, selections, onToggle, malformedOnly, onToggleMalformed,
|
|
onClear, hasActiveFilters,
|
|
}) {
|
|
// The API returns facets in field order with `state` last; render in that
|
|
// order. `selections` is { field: Set<string> }.
|
|
const fieldOrder = Object.keys(facets || {})
|
|
// A `tags`-type field renders the value-search box.
|
|
const isTags = (field) => fields?.[field]?.type === 'tags'
|
|
return (
|
|
<div className="facets">
|
|
{hasActiveFilters && (
|
|
<button className="facet-clear" onClick={onClear}>Clear filters</button>
|
|
)}
|
|
{fieldOrder.map(field => (
|
|
<FacetGroup
|
|
key={field}
|
|
field={field}
|
|
fields={fields}
|
|
counts={facets[field]}
|
|
selected={selections[field] || new Set()}
|
|
onToggle={onToggle}
|
|
isTags={isTags(field)}
|
|
/>
|
|
))}
|
|
<label className="facet-malformed">
|
|
<input
|
|
type="checkbox"
|
|
checked={malformedOnly}
|
|
onChange={onToggleMalformed}
|
|
/>
|
|
<span>Malformed metadata only</span>
|
|
</label>
|
|
</div>
|
|
)
|
|
}
|