feat(slice3): FacetGroups faceted-pane component (§22.4a §5.1)
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user