feat(slice3): FacetGroups faceted-pane component (§22.4a §5.1)

This commit is contained in:
Ben Stull
2026-06-07 17:38:35 -07:00
parent bcce40d2cb
commit ae083bfcaa
+97
View File
@@ -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>
)
}