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