@stapel/search-react 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/api/generated/schema.d.ts +18 -10
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FacetGroupControl.d.ts +10 -0
  5. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  6. package/dist/default/FacetGroupControl.js +122 -27
  7. package/dist/default/FacetGroupControl.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts +25 -38
  9. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  10. package/dist/default/FacetPanelPane.js +157 -6
  11. package/dist/default/FacetPanelPane.js.map +1 -1
  12. package/dist/default/FilterChips.d.ts +25 -0
  13. package/dist/default/FilterChips.d.ts.map +1 -1
  14. package/dist/default/FilterChips.js +39 -3
  15. package/dist/default/FilterChips.js.map +1 -1
  16. package/dist/default/SearchPage.d.ts.map +1 -1
  17. package/dist/default/SearchPage.js +10 -1
  18. package/dist/default/SearchPage.js.map +1 -1
  19. package/dist/default/SearchResultsPane.d.ts +9 -1
  20. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  21. package/dist/default/SearchResultsPane.js +20 -7
  22. package/dist/default/SearchResultsPane.js.map +1 -1
  23. package/dist/default/index.d.ts +1 -1
  24. package/dist/default/index.d.ts.map +1 -1
  25. package/dist/default/index.js +1 -1
  26. package/dist/default/index.js.map +1 -1
  27. package/dist/i18n/es.d.ts.map +1 -1
  28. package/dist/i18n/es.js +5 -0
  29. package/dist/i18n/es.js.map +1 -1
  30. package/dist/i18n/keys.d.ts +19 -0
  31. package/dist/i18n/keys.d.ts.map +1 -1
  32. package/dist/i18n/keys.js +25 -0
  33. package/dist/i18n/keys.js.map +1 -1
  34. package/dist/i18n/ru.d.ts.map +1 -1
  35. package/dist/i18n/ru.js +7 -0
  36. package/dist/i18n/ru.js.map +1 -1
  37. package/dist/state/facets.d.ts +14 -0
  38. package/dist/state/facets.d.ts.map +1 -1
  39. package/dist/state/facets.js +19 -0
  40. package/dist/state/facets.js.map +1 -1
  41. package/llms.txt +2 -2
  42. package/manifest.json +8 -2
  43. package/nav-manifest.json +1 -1
  44. package/package.json +7 -7
  45. package/src/analytics/generated/events.json +1 -1
  46. package/src/api/generated/schema.ts +18 -10
  47. package/src/default/FacetGroupControl.tsx +217 -60
  48. package/src/default/FacetPanelPane.tsx +226 -25
  49. package/src/default/FilterChips.tsx +69 -2
  50. package/src/default/SearchPage.tsx +15 -1
  51. package/src/default/SearchResultsPane.tsx +20 -7
  52. package/src/default/index.ts +2 -0
  53. package/src/i18n/es.ts +5 -0
  54. package/src/i18n/keys.ts +25 -0
  55. package/src/i18n/ru.ts +7 -0
  56. package/src/state/facets.ts +19 -0
@@ -31,6 +31,31 @@
31
31
  * A group with NO schema (the host passed no `categoryFeatures`, or the slug
32
32
  * is not in it) is a flat checkbox list — the honest default, and the shape
33
33
  * every group had before.
34
+ *
35
+ * ── The group can be a disclosure, and closed it is NOT in the DOM ─────────
36
+ *
37
+ * Measured on a live classified deployment's cars leaf at 1440×900: the 280px
38
+ * rail carried 5717px of content — 40 groups, 118 checkboxes, 66 fields — as
39
+ * one flat column. No amount of per-group folding fixes forty headings' worth
40
+ * of open controls, so the group itself can close: `collapsible` turns the
41
+ * label into a real `<button aria-expanded>` with a chevron and, when values
42
+ * are chosen inside, the count of them — the one fact a closed group owes its
43
+ * header. Closed means the options are not rendered at all: a hundred
44
+ * `display:none` checkboxes are still a hundred stops for a screen reader.
45
+ * Both props default to today's behaviour (always open, no disclosure), so no
46
+ * existing host changes; WHICH groups open is the panel's decision, not this
47
+ * component's — see `FacetPanelPane`.
48
+ *
49
+ * ── Uncounted options are the fold's tail, not the group's face ────────────
50
+ *
51
+ * The same walk found "not counted" printed 100+ times down the default view.
52
+ * The sentence is honest and it stays — but an option with no evidence behind
53
+ * it must not stand in front of one that has some, so options with
54
+ * `count: null` and nothing chosen sort AFTER every counted one and live
55
+ * behind the existing "Show all (N)" fold. A group whose options are ALL
56
+ * uncounted (a schema-only group — the server never counted the slug) keeps
57
+ * them visible as before: folding everything would leave a heading over
58
+ * nothing. Chosen options are always visible, wherever their count went.
34
59
  */
35
60
  import { useState } from "react";
36
61
  import type { CSSProperties, ReactElement } from "react";
@@ -114,28 +139,50 @@ export function facetGroupShape(group: FacetGroup): FacetGroupShape {
114
139
  return singleChoice(feature) ? "segmented" : "checkbox";
115
140
  }
116
141
 
142
+ /**
143
+ * Is this node in the group's uncounted tail — an option with no evidence
144
+ * behind it AND no choice on it? Only meaningful in a group that has counted
145
+ * evidence at all: see {@link facetOptionNodes}.
146
+ */
147
+ function uncountedUnchosen(node: FacetOptionNode): boolean {
148
+ return node.option.count === null && !node.option.selected;
149
+ }
150
+
117
151
  /** The group's options in render order, carrying the depth the schema gives. */
118
152
  export function facetOptionNodes(group: FacetGroup): readonly FacetOptionNode[] {
153
+ let nodes: readonly FacetOptionNode[];
119
154
  if (facetGroupShape(group) !== "nested" || group.feature === undefined) {
120
- return group.options.map((option) => ({ option, depth: 0 }));
121
- }
122
- const depths = optionDepths(featureConfig(group.feature)["options"]);
123
- // Depth alone would leave children beside strangers: the options are
124
- // re-ordered so each child follows its own parent, and a value the schema
125
- // does not know keeps depth 0 rather than being hidden under someone.
126
- const byValue = new Map(group.options.map((option) => [option.value, option]));
127
- const out: FacetOptionNode[] = [];
128
- const emitted = new Set<string>();
129
- for (const [value, depth] of depths) {
130
- const option = byValue.get(value);
131
- if (option === undefined) continue;
132
- out.push({ option, depth });
133
- emitted.add(value);
134
- }
135
- for (const option of group.options) {
136
- if (!emitted.has(option.value)) out.push({ option, depth: 0 });
155
+ nodes = group.options.map((option) => ({ option, depth: 0 }));
156
+ } else {
157
+ const depths = optionDepths(featureConfig(group.feature)["options"]);
158
+ // Depth alone would leave children beside strangers: the options are
159
+ // re-ordered so each child follows its own parent, and a value the schema
160
+ // does not know keeps depth 0 rather than being hidden under someone.
161
+ const byValue = new Map(group.options.map((option) => [option.value, option]));
162
+ const out: FacetOptionNode[] = [];
163
+ const emitted = new Set<string>();
164
+ for (const [value, depth] of depths) {
165
+ const option = byValue.get(value);
166
+ if (option === undefined) continue;
167
+ out.push({ option, depth });
168
+ emitted.add(value);
169
+ }
170
+ for (const option of group.options) {
171
+ if (!emitted.has(option.value)) out.push({ option, depth: 0 });
172
+ }
173
+ nodes = out;
137
174
  }
138
- return out;
175
+
176
+ // An option nobody counted must not stand in front of one with evidence:
177
+ // the uncounted, unchosen tail sorts after everything else (stable — both
178
+ // halves keep their own order) and folds behind "Show all" in the
179
+ // component below. Only in a group that HAS counted evidence: a schema-only
180
+ // group is all `null`, and demoting all of it would reorder nothing while
181
+ // telling the fold to hide the entire group behind its own heading.
182
+ if (!group.options.some((option) => option.count !== null)) return nodes;
183
+ const tail = nodes.filter(uncountedUnchosen);
184
+ if (tail.length === 0) return nodes;
185
+ return [...nodes.filter((node) => !uncountedUnchosen(node)), ...tail];
139
186
  }
140
187
 
141
188
  /** A count, or the honest "we did not count this". Never a zero standing in
@@ -229,6 +276,57 @@ function OptionPill(props: {
229
276
  );
230
277
  }
231
278
 
279
+ /**
280
+ * The disclosure header, as a NATIVE button with the chrome stripped.
281
+ *
282
+ * Native rather than antd's `Button`, because this is a heading that happens
283
+ * to toggle, not an action: it must inherit the heading's type and colour and
284
+ * sit flush with the group's left edge, and undoing a themed button's
285
+ * padding, border, background and hover for every surface it lands on is more
286
+ * style than the button brings. `aria-expanded` on a real `<button>` is the
287
+ * whole disclosure pattern; the chevron only draws what it already says.
288
+ */
289
+ const DISCLOSURE_HEADER: CSSProperties = {
290
+ display: "flex",
291
+ alignItems: "center",
292
+ gap: spacing[1],
293
+ width: "100%",
294
+ padding: 0,
295
+ border: "none",
296
+ background: "none",
297
+ color: "inherit",
298
+ font: "inherit",
299
+ textAlign: "start",
300
+ cursor: "pointer",
301
+ };
302
+
303
+ /** The disclosure's state, drawn. Points down over a closed group ("there is
304
+ * more below") and flips once it is open. */
305
+ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
306
+ return (
307
+ <svg
308
+ width="16"
309
+ height="16"
310
+ viewBox="0 0 24 24"
311
+ fill="none"
312
+ stroke="currentColor"
313
+ strokeWidth="2"
314
+ strokeLinecap="round"
315
+ strokeLinejoin="round"
316
+ role="img"
317
+ aria-hidden="true"
318
+ focusable="false"
319
+ style={{
320
+ marginInlineStart: "auto",
321
+ flex: "0 0 auto",
322
+ ...(props.open ? { transform: "rotate(180deg)" } : {}),
323
+ }}
324
+ >
325
+ <path d="m6 9 6 6 6-6" />
326
+ </svg>
327
+ );
328
+ }
329
+
232
330
  export interface FacetGroupControlProps {
233
331
  readonly group: FacetGroup;
234
332
  readonly onToggle: (slug: string, value: string) => void;
@@ -238,23 +336,48 @@ export interface FacetGroupControlProps {
238
336
  /** How many options before "Show all". Default {@link FACET_VISIBLE_OPTIONS};
239
337
  * `null` shows every option (a sheet devoted to one group has the room). */
240
338
  readonly visibleOptions?: number | null;
339
+ /**
340
+ * Make the group a disclosure: the heading becomes a real button and the
341
+ * options can leave the DOM entirely. Default `false` — today's always-open
342
+ * group, so no existing host changes. Meaningless with `heading: false`:
343
+ * a disclosure with no header is a group nothing can reopen.
344
+ */
345
+ readonly collapsible?: boolean;
346
+ /** Whether a `collapsible` group STARTS open. Default `true`. The initial
347
+ * value only — the person owns the state after the first click. */
348
+ readonly defaultOpen?: boolean;
241
349
  }
242
350
 
243
351
  export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
244
352
  const t = useT();
245
353
  const { group } = props;
246
354
  const [expanded, setExpanded] = useState(false);
355
+ const [openState, setOpenState] = useState(props.defaultOpen !== false);
247
356
  const shape = facetGroupShape(group);
248
357
  const nodes = facetOptionNodes(group);
249
358
 
359
+ const disclosure = props.collapsible === true && props.heading !== false;
360
+ const open = !disclosure || openState;
361
+
250
362
  const limit =
251
363
  props.visibleOptions === null
252
364
  ? null
253
365
  : (props.visibleOptions ?? FACET_VISIBLE_OPTIONS);
366
+ // The demoted tail `facetOptionNodes` sorted to the end: uncounted,
367
+ // unchosen options in a group that has counted evidence. They are ALWAYS
368
+ // behind the fold — the "one row over the limit" exemption below is about
369
+ // not hiding one counted row, and these rows say "not counted".
370
+ const demoted = group.options.some((option) => option.count !== null)
371
+ ? nodes.filter(uncountedUnchosen).length
372
+ : 0;
254
373
  // A group one row over the limit is not truncated: hiding a single option
255
374
  // behind "Show all (9)" costs a tap to reveal exactly one thing.
256
- const collapsible = limit !== null && nodes.length > limit + 1;
257
- const shown = collapsible && !expanded ? nodes.slice(0, limit) : nodes;
375
+ const folded =
376
+ limit !== null && (nodes.length > limit + 1 || demoted > 0);
377
+ const shown =
378
+ folded && !expanded
379
+ ? nodes.slice(0, Math.min(limit ?? nodes.length, nodes.length - demoted))
380
+ : nodes;
258
381
 
259
382
  return (
260
383
  <Flex
@@ -264,48 +387,82 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
264
387
  data-counted={group.counted ? "true" : "false"}
265
388
  data-shape={shape}
266
389
  >
267
- {props.heading !== false && (
268
- <Typography.Text strong>{group.label}</Typography.Text>
269
- )}
390
+ {props.heading !== false &&
391
+ (disclosure ? (
392
+ <button
393
+ type="button"
394
+ style={DISCLOSURE_HEADER}
395
+ aria-expanded={open}
396
+ data-testid={`facet-toggle-${group.slug}`}
397
+ data-analytics="none"
398
+ data-analytics-reason="opening a filter group is a read, not a flow step"
399
+ onClick={() => {
400
+ setOpenState((was) => !was);
401
+ }}
402
+ >
403
+ <Typography.Text strong>{group.label}</Typography.Text>
404
+ {/* The one fact a closed group owes its header: how many of its
405
+ values are CHOSEN. Not the option count — that is what the
406
+ fold's own "Show all (N)" answers once the group is open. */}
407
+ {group.selected.length > 0 && (
408
+ <Typography.Text
409
+ type="secondary"
410
+ data-testid={`facet-toggle-count-${group.slug}`}
411
+ >
412
+ {group.selected.length}
413
+ </Typography.Text>
414
+ )}
415
+ <ChevronGlyph open={open} />
416
+ </button>
417
+ ) : (
418
+ <Typography.Text strong>{group.label}</Typography.Text>
419
+ ))}
270
420
 
271
- {shape === "segmented" ? (
272
- <Flex wrap gap={spacing[2]}>
273
- {shown.map((node) => (
274
- <OptionPill
275
- key={node.option.value}
276
- group={group}
277
- option={node.option}
278
- onToggle={props.onToggle}
279
- />
280
- ))}
281
- </Flex>
282
- ) : (
283
- shown.map((node) => (
284
- <CheckboxRow
285
- key={node.option.value}
286
- group={group}
287
- node={node}
288
- onToggle={props.onToggle}
289
- />
290
- ))
291
- )}
421
+ {/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
422
+ checkboxes are still a hundred stops for a screen reader, and the
423
+ measured rail held 118 of them. */}
424
+ {open && (
425
+ <>
426
+ {shape === "segmented" ? (
427
+ <Flex wrap gap={spacing[2]}>
428
+ {shown.map((node) => (
429
+ <OptionPill
430
+ key={node.option.value}
431
+ group={group}
432
+ option={node.option}
433
+ onToggle={props.onToggle}
434
+ />
435
+ ))}
436
+ </Flex>
437
+ ) : (
438
+ shown.map((node) => (
439
+ <CheckboxRow
440
+ key={node.option.value}
441
+ group={group}
442
+ node={node}
443
+ onToggle={props.onToggle}
444
+ />
445
+ ))
446
+ )}
292
447
 
293
- {collapsible && (
294
- <Button
295
- type="link"
296
- size="small"
297
- style={{ alignSelf: "flex-start", paddingInline: 0 }}
298
- data-testid={`facet-more-${group.slug}`}
299
- data-analytics="none"
300
- data-analytics-reason="expanding a filter group is a read, not a flow step"
301
- onClick={() => {
302
- setExpanded((was) => !was);
303
- }}
304
- >
305
- {expanded
306
- ? t(SEARCH_I18N_KEYS.facetsShowLess)
307
- : t(SEARCH_I18N_KEYS.facetsShowAll, { count: nodes.length })}
308
- </Button>
448
+ {folded && (
449
+ <Button
450
+ type="link"
451
+ size="small"
452
+ style={{ alignSelf: "flex-start", paddingInline: 0 }}
453
+ data-testid={`facet-more-${group.slug}`}
454
+ data-analytics="none"
455
+ data-analytics-reason="expanding a filter group is a read, not a flow step"
456
+ onClick={() => {
457
+ setExpanded((was) => !was);
458
+ }}
459
+ >
460
+ {expanded
461
+ ? t(SEARCH_I18N_KEYS.facetsShowLess)
462
+ : t(SEARCH_I18N_KEYS.facetsShowAll, { count: nodes.length })}
463
+ </Button>
464
+ )}
465
+ </>
309
466
  )}
310
467
  </Flex>
311
468
  );
@@ -35,17 +35,44 @@
35
35
  * than a silent absence — and, in every build, any constraint the URL already
36
36
  * carries gets a control that REMOVES it. A shared link that narrows to a
37
37
  * category or a point must never leave a person with no way to widen it again.
38
+ *
39
+ * ── The panel opens what the answer argues for, and closes the rest ────────
40
+ *
41
+ * Measured on a live classified deployment's cars leaf at 1440×900: this
42
+ * panel, in a 280px rail, was 5717px tall — 40 groups, 118 checkboxes, 66
43
+ * fields, one flat column whose tail no scroll a person actually performs
44
+ * ever reaches. The phone sheet drew the same column six screens deep.
45
+ *
46
+ * So the groups are disclosures now, and WHICH open is decided here, from
47
+ * evidence the panel already holds: a group with any chosen value is always
48
+ * open (a constraint must keep its control in sight), and otherwise the top
49
+ * {@link FACET_OPEN_GROUPS} counted groups by {@link facetCoverage} — the sum
50
+ * of an axis's counts is the answer's own statement of how many documents
51
+ * carry it, the same reasoning the chip row sorts its band by. Everything
52
+ * else starts as a header, one click from whole. A group the server never
53
+ * counted sums to zero and therefore never opens uninvited, which is what
54
+ * removes the wall of "not counted" rows from the default view without
55
+ * deleting one of them.
56
+ *
57
+ * From {@link FACET_SEARCH_THRESHOLD} groups up the panel also takes a search
58
+ * of ITSELF — a person who cannot scan forty headers should not have to open
59
+ * them one by one to find the axle count. It narrows by group or option
60
+ * label, opens what it matches, and touches presentation only: the URL is
61
+ * state and a panel query is not.
38
62
  */
63
+ import { useState } from "react";
39
64
  import type { CSSProperties, ReactElement, ReactNode } from "react";
40
65
  import {
41
66
  Alert,
42
67
  Button,
43
68
  Divider,
44
69
  Flex,
70
+ Input,
45
71
  InputNumber,
46
72
  Typography,
73
+ theme,
47
74
  } from "antd";
48
- import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
75
+ import { SlotPlaceholder, isDevBuild, useT, useTPlural } from "@stapel/core";
49
76
  import {
50
77
  EmptyState,
51
78
  ErrorAlert,
@@ -59,6 +86,9 @@ import type { SearchGeo } from "../api/types.js";
59
86
  import { FacetPanel } from "../headless/FacetPanel.js";
60
87
  import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
61
88
  import { useSearchState } from "../headless/SearchStateProvider.js";
89
+ import { useAppliedCount } from "../headless/useAppliedCount.js";
90
+ import { facetCoverage } from "../state/facets.js";
91
+ import type { FacetGroup } from "../state/facets.js";
62
92
  import { FacetGroupControl } from "./FacetGroupControl.js";
63
93
  import { buildRangeGroups } from "../state/ranges.js";
64
94
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
@@ -123,6 +153,21 @@ const FACET_CLEAR: CSSProperties = {
123
153
  textAlign: "start",
124
154
  };
125
155
 
156
+ /**
157
+ * How many counted groups open by default — beyond the ones with chosen
158
+ * values, which always do. Five open groups at the default type step fill
159
+ * roughly one 900px window of the rail: the panel's first screen is the five
160
+ * axes this corpus is most narrowed by, and everything after is a header.
161
+ */
162
+ export const FACET_OPEN_GROUPS = 5;
163
+
164
+ /**
165
+ * From how many groups the panel offers a search of itself. Under six the
166
+ * headers fit a glance and a box would be chrome over nothing; the measured
167
+ * leaf had forty.
168
+ */
169
+ export const FACET_SEARCH_THRESHOLD = 6;
170
+
126
171
  /** What a host's category control is handed. */
127
172
  export interface CategoryFilterSlotProps {
128
173
  /** The `root/leaf` path the search is narrowed to, if any. */
@@ -210,6 +255,87 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
210
255
  * on a staging surface. Nothing else changes with it.
211
256
  */
212
257
  readonly skippedNotice?: boolean;
258
+ /**
259
+ * Draw the sticky footer inside the panel: the live result count as the
260
+ * bar's strong text, and the clear-all control (which then moves out of the
261
+ * heading row — one control, not two) beside it. Default `false`.
262
+ *
263
+ * `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
264
+ * apply instantly, so the bar is FEEDBACK plus the way out, not an apply
265
+ * button — which is exactly why the phone sheet must not get it: the sheet
266
+ * already closes through its own "Show N results" footer, and a second
267
+ * count-bearing bar above that one would be the same sentence twice.
268
+ */
269
+ readonly footerBar?: boolean;
270
+ }
271
+
272
+ /**
273
+ * The rail's sticky floor: what the filters DID (the live count), and the way
274
+ * out of them (clear all). It sticks to the bottom of the rail's own scroll,
275
+ * so however deep the panel goes the answer stays on screen.
276
+ *
277
+ * The count reuses `useAppliedCount` — the answer already in cache, never a
278
+ * second request — and its honesty rules: an exact count gets the counted
279
+ * noun ("N listings match"), a floor keeps the existing "N+" family, and an
280
+ * engine that cannot say gets NO number, because a fabricated one on the
281
+ * surface that reports what filtering did is worse than silence. With
282
+ * nothing to say and nothing to clear, no bar.
283
+ */
284
+ function RailFooterBar(props: {
285
+ readonly activeFilters: number;
286
+ readonly clearAll: () => void;
287
+ }): ReactElement | null {
288
+ const t = useT();
289
+ const tPlural = useTPlural();
290
+ // The token bag of the nearest theme, so the bar's ground and hairline are
291
+ // the panel's own in both modes — a hard-coded white floor would glow in
292
+ // the dark theme.
293
+ const { token } = theme.useToken();
294
+ const applied = useAppliedCount();
295
+ const countText =
296
+ applied.count === null || applied.kind === "unknown"
297
+ ? null
298
+ : tPlural(
299
+ applied.kind === "at_least"
300
+ ? SEARCH_I18N_KEYS.resultsCountAtLeast
301
+ : SEARCH_I18N_KEYS.facetsMatchCount,
302
+ { count: applied.count }
303
+ );
304
+ if (countText === null && props.activeFilters === 0) return null;
305
+ return (
306
+ <div
307
+ data-testid="facets-footer-bar"
308
+ style={{
309
+ position: "sticky",
310
+ bottom: 0,
311
+ // Opaque, or the options scrolling under the bar read THROUGH it.
312
+ background: token.colorBgContainer,
313
+ borderBlockStart: `1px solid ${token.colorSplit}`,
314
+ paddingBlockStart: spacing[2],
315
+ display: "flex",
316
+ flexDirection: "column",
317
+ alignItems: "flex-start",
318
+ gap: spacing[2],
319
+ }}
320
+ >
321
+ {countText !== null && (
322
+ <Typography.Text strong data-testid="facets-footer-count">
323
+ {countText}
324
+ </Typography.Text>
325
+ )}
326
+ {props.activeFilters > 0 && (
327
+ <Button
328
+ style={FACET_CLEAR}
329
+ onClick={props.clearAll}
330
+ data-analytics="none"
331
+ data-analytics-reason="a filter is a read, not a flow step"
332
+ data-testid="facets-clear-all"
333
+ >
334
+ {t(SEARCH_I18N_KEYS.facetsClearAll, { count: props.activeFilters })}
335
+ </Button>
336
+ )}
337
+ </div>
338
+ );
213
339
  }
214
340
 
215
341
  /** The category constraint: the host's control, or the door out of it. */
@@ -401,6 +527,10 @@ function skippedNames(
401
527
  export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
402
528
  const t = useT();
403
529
  const { state } = useSearchState();
530
+ // The panel-search's text. COMPONENT state on purpose: it narrows how the
531
+ // panel is drawn, never what the search is, so it must not survive into a
532
+ // shared link the way everything in `useSearchState` does.
533
+ const [filterQuery, setFilterQuery] = useState("");
404
534
 
405
535
  return (
406
536
  <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
@@ -455,7 +585,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
455
585
  {props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
456
586
  </Typography.Title>
457
587
  )}
458
- {bag.activeFilters > 0 && (
588
+ {/* With the footer bar on, clear-all lives THERE — beside the
589
+ count it acts on — and drawing it here too would be two
590
+ identical exits one panel apart. */}
591
+ {bag.activeFilters > 0 && props.footerBar !== true && (
459
592
  <Button
460
593
  style={FACET_CLEAR}
461
594
  onClick={bag.clearAll}
@@ -538,35 +671,103 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
538
671
  />
539
672
  )}
540
673
  >
541
- {(groups) => (
542
- <Flex vertical gap={spacing[4]}>
543
- {/* A group with no options is a heading with nothing under
544
- it. What is left in that state after `buildFacetGroups`
545
- learned to read the schema is the genuinely unanswerable
546
- case: a `ref_select` whose config is a bare pointer into
547
- a vocabulary this pair cannot read. A heading with no
548
- control under it names nothing, so it is not drawn. */}
549
- {/* Each group draws itself the way its own schema says:
550
- pills for a single-choice facet, indented children for a
551
- hierarchical one, a fold for a long one. The panel does
552
- not decide `facetGroupShape` reads the same config keys
553
- the attributes editor reads, so a facet cannot look one
554
- way here and another way in the composer. */}
555
- {groups
556
- .filter((group) => group.options.length > 0)
557
- .map((group) => (
674
+ {(groups) => {
675
+ // A group with no options is a heading with nothing under
676
+ // it. What is left in that state after `buildFacetGroups`
677
+ // learned to read the schema is the genuinely unanswerable
678
+ // case: a `ref_select` whose config is a bare pointer into
679
+ // a vocabulary this pair cannot read. A heading with no
680
+ // control under it names nothing, so it is not drawn.
681
+ const drawable = groups.filter(
682
+ (group) => group.options.length > 0
683
+ );
684
+ // Which groups OPEN see the module note. Chosen groups are
685
+ // open unconditionally below; here the answer's evidence
686
+ // picks the rest: the top counted groups by coverage, and a
687
+ // group the server never counted sums to zero, so the wall
688
+ // of "not counted" rows starts as headers.
689
+ const openByEvidence = new Set(
690
+ drawable
691
+ .filter((group) => group.counted)
692
+ .map((group) => [group, facetCoverage(group)] as const)
693
+ .sort((a, b) => b[1] - a[1])
694
+ .slice(0, FACET_OPEN_GROUPS)
695
+ .map(([group]) => group.slug)
696
+ );
697
+ const searchable = drawable.length >= FACET_SEARCH_THRESHOLD;
698
+ const needle = searchable
699
+ ? filterQuery.trim().toLowerCase()
700
+ : "";
701
+ const matches = (group: FacetGroup): boolean =>
702
+ group.label.toLowerCase().includes(needle) ||
703
+ group.options.some((option) =>
704
+ option.label.toLowerCase().includes(needle)
705
+ );
706
+ const listed =
707
+ needle === "" ? drawable : drawable.filter(matches);
708
+ return (
709
+ <Flex vertical gap={spacing[4]}>
710
+ {searchable && (
711
+ <Input
712
+ allowClear
713
+ value={filterQuery}
714
+ placeholder={t(SEARCH_I18N_KEYS.facetsSearch)}
715
+ aria-label={t(SEARCH_I18N_KEYS.facetsSearch)}
716
+ data-testid="facets-search"
717
+ onChange={(event) => {
718
+ setFilterQuery(event.target.value);
719
+ }}
720
+ />
721
+ )}
722
+ {/* The query missed. The groups are still there — one
723
+ cleared box away — so this is the panel-search's empty
724
+ state, not the panel's. */}
725
+ {needle !== "" && listed.length === 0 && (
726
+ <EmptyState
727
+ compact
728
+ title={t(SEARCH_I18N_KEYS.facetsSearchEmpty)}
729
+ testId="facets-search-empty"
730
+ />
731
+ )}
732
+ {/* Each group draws itself the way its own schema says:
733
+ pills for a single-choice facet, indented children for a
734
+ hierarchical one, a fold for a long one. The panel does
735
+ not decide — `facetGroupShape` reads the same config keys
736
+ the attributes editor reads, so a facet cannot look one
737
+ way here and another way in the composer. */}
738
+ {/* The key changes with the query's presence ON PURPOSE:
739
+ `defaultOpen` is an initial value, and a group the
740
+ panel-search matched has to render OPEN — a hit behind
741
+ a closed header is not an answer. Remounting is the
742
+ honest way to re-ask the question; the person's own
743
+ opens and closes come back when the box clears. */}
744
+ {listed.map((group) => (
558
745
  <FacetGroupControl
559
- key={group.slug}
746
+ key={needle === "" ? group.slug : `${group.slug}:match`}
560
747
  group={group}
561
748
  onToggle={bag.toggle}
749
+ collapsible
750
+ defaultOpen={
751
+ needle !== "" ||
752
+ group.selected.length > 0 ||
753
+ openByEvidence.has(group.slug)
754
+ }
562
755
  />
563
756
  ))}
564
- <Typography.Text type="secondary">
565
- {t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
566
- </Typography.Text>
567
- </Flex>
568
- )}
757
+ <Typography.Text type="secondary">
758
+ {t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
759
+ </Typography.Text>
760
+ </Flex>
761
+ );
762
+ }}
569
763
  </LoadList>
764
+
765
+ {props.footerBar === true && (
766
+ <RailFooterBar
767
+ activeFilters={bag.activeFilters}
768
+ clearAll={bag.clearAll}
769
+ />
770
+ )}
570
771
  </Flex>
571
772
  );
572
773
  }}