@stapel/search-react 0.16.0 → 0.18.1

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 (60) hide show
  1. package/CHANGELOG.md +122 -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/SortSelect.d.ts.map +1 -1
  24. package/dist/default/SortSelect.js +4 -1
  25. package/dist/default/SortSelect.js.map +1 -1
  26. package/dist/default/index.d.ts +1 -1
  27. package/dist/default/index.d.ts.map +1 -1
  28. package/dist/default/index.js +1 -1
  29. package/dist/default/index.js.map +1 -1
  30. package/dist/i18n/es.d.ts.map +1 -1
  31. package/dist/i18n/es.js +5 -0
  32. package/dist/i18n/es.js.map +1 -1
  33. package/dist/i18n/keys.d.ts +19 -0
  34. package/dist/i18n/keys.d.ts.map +1 -1
  35. package/dist/i18n/keys.js +25 -0
  36. package/dist/i18n/keys.js.map +1 -1
  37. package/dist/i18n/ru.d.ts.map +1 -1
  38. package/dist/i18n/ru.js +7 -0
  39. package/dist/i18n/ru.js.map +1 -1
  40. package/dist/state/facets.d.ts +14 -0
  41. package/dist/state/facets.d.ts.map +1 -1
  42. package/dist/state/facets.js +19 -0
  43. package/dist/state/facets.js.map +1 -1
  44. package/llms.txt +2 -2
  45. package/manifest.json +8 -2
  46. package/nav-manifest.json +1 -1
  47. package/package.json +7 -7
  48. package/src/analytics/generated/events.json +1 -1
  49. package/src/api/generated/schema.ts +18 -10
  50. package/src/default/FacetGroupControl.tsx +217 -60
  51. package/src/default/FacetPanelPane.tsx +226 -25
  52. package/src/default/FilterChips.tsx +69 -2
  53. package/src/default/SearchPage.tsx +15 -1
  54. package/src/default/SearchResultsPane.tsx +20 -7
  55. package/src/default/SortSelect.tsx +3 -1
  56. package/src/default/index.ts +2 -0
  57. package/src/i18n/es.ts +5 -0
  58. package/src/i18n/keys.ts +25 -0
  59. package/src/i18n/ru.ts +7 -0
  60. package/src/state/facets.ts +19 -0
@@ -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
  }}
@@ -133,6 +133,7 @@ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
133
133
  import { useSearchState } from "../headless/SearchStateProvider.js";
134
134
  import { buildRangeGroups } from "../state/ranges.js";
135
135
  import type { RangeGroup } from "../state/ranges.js";
136
+ import { facetCoverage } from "../state/facets.js";
136
137
  import type { FacetGroup } from "../state/facets.js";
137
138
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
138
139
  import { FacetGroupControl } from "./FacetGroupControl.js";
@@ -242,10 +243,48 @@ export function orderChipFilters(
242
243
  return [...specs].sort((a, b) => {
243
244
  const applied = Number(specApplied(b)) - Number(specApplied(a));
244
245
  if (applied !== 0) return applied;
245
- return CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
246
+ const band = CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
247
+ if (band !== 0) return band;
248
+ // WITHIN the counted-facet band: coverage — the answer's own evidence of
249
+ // which axes this corpus actually fills (D16 reopen: an imported
250
+ // catalogue gave the phones leaf option tables for its wholesale
251
+ // plumbing, and schema order put them ahead of the brand). A group the
252
+ // server did not count sums to zero and trails every counted one; ties
253
+ // keep the authored order because the sort is stable.
254
+ if (a.band === "facet" && b.band === "facet") {
255
+ return facetCoverage(b.facet) - facetCoverage(a.facet);
256
+ }
257
+ return 0;
246
258
  });
247
259
  }
248
260
 
261
+ /**
262
+ * How many banded chips the row draws before the "more" door — enough for the
263
+ * axes with real evidence behind them, small enough that the tail of a
264
+ * 44-axis cars leaf lives in the panel instead of in four flicks. A host
265
+ * passes `maxRowChips` to move it, or `null` to disable the door.
266
+ */
267
+ export const CHIP_ROW_CAP = 8;
268
+
269
+ /**
270
+ * The visible row and what the door owes: the first `max` specs — and EVERY
271
+ * applied one, however many, because a constraint on screen must keep the
272
+ * control that removes it (the same rule the barren filter follows). The
273
+ * overflow count is what the door chip prints; the chips behind it are not
274
+ * deleted, they are the panel's — one tap behind the door itself.
275
+ */
276
+ export function capChipRow(
277
+ specs: readonly ChipSpec[],
278
+ max: number | null
279
+ ): { readonly visible: readonly ChipSpec[]; readonly overflow: number } {
280
+ if (max === null || specs.length <= max) {
281
+ return { visible: specs, overflow: 0 };
282
+ }
283
+ const applied = specs.filter(specApplied).length;
284
+ const cut = Math.max(max, applied);
285
+ return { visible: specs.slice(0, cut), overflow: specs.length - cut };
286
+ }
287
+
249
288
  export interface FilterChipsProps {
250
289
  /** The category's feature schema — the source of option labels, of which
251
290
  * slugs get a range chip, of which slugs are choosable at all, and of how
@@ -295,6 +334,13 @@ export interface FilterChipsProps {
295
334
  /** Open the whole panel — the leading chip's action. The page owns that
296
335
  * sheet, because the page is the surface it covers. */
297
336
  readonly onOpenAll: () => void;
337
+ /**
338
+ * The row's chip budget before the "more" door — see {@link CHIP_ROW_CAP}
339
+ * (the default). `null` draws every chip, door-less, as the row did before
340
+ * it was capped. Applied filters never count against the budget's loss:
341
+ * they are always drawn.
342
+ */
343
+ readonly maxRowChips?: number | null;
298
344
  /**
299
345
  * Draw the location chip. Default `true`.
300
346
  *
@@ -437,7 +483,11 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
437
483
  // need. Applied ones stay: a constraint always keeps the control that
438
484
  // removes it.
439
485
  const barren = bag.counted.length > 0 && bag.candidates === 0;
440
- const ordered = orderChipFilters(ranges, groups, { barren });
486
+ const orderedAll = orderChipFilters(ranges, groups, { barren });
487
+ const { visible: ordered, overflow } = capChipRow(
488
+ orderedAll,
489
+ props.maxRowChips === undefined ? CHIP_ROW_CAP : props.maxRowChips
490
+ );
441
491
 
442
492
  const geo = state.geo;
443
493
  const showGeoChip =
@@ -569,6 +619,23 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
569
619
  </Button>
570
620
  )
571
621
  )}
622
+
623
+ {/* The "more" door (D16): the capped tail is not deleted, it is one
624
+ tap away — this opens the SAME full panel the leading circle
625
+ does, where every cut control lives whole. The count keeps the
626
+ door honest about how much it is standing in front of. */}
627
+ {overflow > 0 && (
628
+ <Button
629
+ style={CHIP}
630
+ shape="round"
631
+ data-testid="search-chips-overflow"
632
+ data-analytics="none"
633
+ data-analytics-reason="opening the filter sheet is a read, not a flow step"
634
+ onClick={props.onOpenAll}
635
+ >
636
+ {t(SEARCH_I18N_KEYS.filtersChipOverflow, { count: overflow })}
637
+ </Button>
638
+ )}
572
639
  </div>
573
640
 
574
641
  {/* One sheet per chip, rendered only for the open one: a dozen mounted
@@ -139,6 +139,15 @@ const RAIL: CSSProperties = {
139
139
  maxHeight: "100dvh",
140
140
  overflowY: "auto",
141
141
  overscrollBehavior: "contain",
142
+ // The inner scroll must be VISIBLE. On overlay-scrollbar platforms (every
143
+ // Mac by default, most phones) an `overflow-y: auto` column shows no
144
+ // scrollbar until a pointer happens to scroll INSIDE it — so a rail taller
145
+ // than the window is indistinguishable from a rail that ends at the fold,
146
+ // and the walker measured 5717px of panel whose tail nothing signposted.
147
+ // A thin, always-there scrollbar is the sign there is more; the stable
148
+ // gutter keeps the panel's right edge from jumping when it appears.
149
+ scrollbarWidth: "thin",
150
+ scrollbarGutter: "stable",
142
151
  // Room for the focus ring of the last control against the scroll edge.
143
152
  paddingBlockEnd: spacing[2],
144
153
  };
@@ -424,9 +433,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
424
433
  a hole, just a smaller one. */}
425
434
  {/* In the sheet the dialog's own title already says "Filters"; the panel
426
435
  repeating it printed the word twice, one line apart. */}
436
+ {/* Per layout: the sheet gets no second "Filters" title (the dialog owns
437
+ one) and no footer bar — its own "Show N results" footer is already
438
+ the count AND the exit. The rail gets the sticky footer bar: desktop
439
+ filters apply instantly, and without it the only feedback was a
440
+ result count scrolled out of sight above the fold. */}
427
441
  {filtersEmpty ? null : (
428
442
  <FacetPanelPane
429
- {...(layout === "sheet" ? { heading: null } : {})}
443
+ {...(layout === "sheet" ? { heading: null } : { footerBar: true })}
430
444
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
431
445
  {...(props.renderEmptyExits !== undefined
432
446
  ? { renderEmptyExits: props.renderEmptyExits }
@@ -70,20 +70,33 @@ export type SearchResultsRenderer = (
70
70
  * width of whatever it was dropped into — the visual pass measured a 2560px
71
71
  * pane with four cards floating at the top of it and a status row 1350px from
72
72
  * the buttons that act on it (class C-NOMAXW).
73
+ *
74
+ * 1400 and not the 1120 it started at: a card GRID is not prose — its rows
75
+ * have no line to lose the thread of — so its measure is the fleet's widest
76
+ * content column, not a paragraph's. At 1120 the cap quietly overrode the
77
+ * grid's own 260px floor (a 1440px desktop drew three 363px cards inside a
78
+ * 1392px content column, the very hole the floor was lowered to fill); at
79
+ * 1400 the floor decides — five columns open, four beside the filter rail —
80
+ * and the 2560px pane the constant was written against is still capped.
73
81
  */
74
- export const RESULTS_MAX_WIDTH = 1120;
82
+ export const RESULTS_MAX_WIDTH = 1400;
75
83
 
76
84
  /**
77
- * The results grid. `auto-fill` + `minmax(280px, 1fr)`: as many columns as fit,
78
- * each at least a readable card and never wider than its share — a catalogue
79
- * on a 1400px desktop is four columns, not four full-bleed rows, and the same
85
+ * The results grid. `auto-fill` + `minmax(260px, 1fr)`: as many columns as fit,
86
+ * each at least a readable card and never wider than its share — and the same
80
87
  * declaration collapses to one column on a phone with no breakpoint to
81
- * maintain. 280px is the width below which the default card's title, price and
82
- * location stop fitting on their own lines.
88
+ * maintain.
89
+ *
90
+ * The floor was 280px and is 260 deliberately: at a 1400px content measure the
91
+ * grid now draws five columns instead of three wide ones, and beside the 280px
92
+ * filter rail, four. The default card's own minimum — title, price and
93
+ * location each on their own line — still fits at 260; the 20px the old floor
94
+ * bought went into card whitespace, not into legibility, and cost a whole
95
+ * column of listings on the reference desktop.
83
96
  */
84
97
  const RESULTS_GRID: CSSProperties = {
85
98
  display: "grid",
86
- gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
99
+ gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
87
100
  gap: spacing[3],
88
101
  alignItems: "stretch",
89
102
  };
@@ -136,7 +136,9 @@ export function SortSelect(props: SortSelectProps): ReactElement {
136
136
  }
137
137
 
138
138
  return (
139
- <GatedControl gate={distance} testId="search-sort-gate">
139
+ // `annotate`: the gate judges ONE option, not the control. Suppressing
140
+ // the select would take away every sort, which is not what is blocked.
141
+ <GatedControl gate={distance} whenBlocked="annotate" testId="search-sort-gate">
140
142
  {(bind) => (
141
143
  <Flex gap={spacing[2]} align="center">
142
144
  <Typography.Text type="secondary" aria-hidden="true">
@@ -51,8 +51,10 @@ export type {
51
51
  export {
52
52
  FilterChips,
53
53
  CHIP_BAND_ORDER,
54
+ CHIP_ROW_CAP,
54
55
  CHIP_ROW_CLASS,
55
56
  CHIP_ROW_STYLE_HREF,
57
+ capChipRow,
56
58
  categoryLeaf,
57
59
  chipRowCss,
58
60
  orderChipFilters,
package/src/i18n/es.ts CHANGED
@@ -87,6 +87,10 @@ export const searchI18nBundleEs: I18nDictionary = {
87
87
  "search.facets.range_to_aria": "{feature}, hasta",
88
88
  "search.facets.show_all": "Ver todos ({count})",
89
89
  "search.facets.show_less": "Ver menos",
90
+ "search.facets.search": "Buscar un filtro",
91
+ "search.facets.search_empty": "Ningún filtro coincide con esto",
92
+ "search.facets.match_count.one": "{count} anuncio coincide",
93
+ "search.facets.match_count.other": "{count} anuncios coinciden",
90
94
  "search.facets.range_invalid":
91
95
  "«Desde» es mayor que «hasta», así que nada podría coincidir. Intercámbialos para aplicar el rango.",
92
96
 
@@ -101,6 +105,7 @@ export const searchI18nBundleEs: I18nDictionary = {
101
105
  "search.filters.all": "Todos los filtros",
102
106
  "search.filters.short": "Filtros",
103
107
  "search.filters.chip_more": ", +{count}",
108
+ "search.filters.chips_overflow": "Más · {count}",
104
109
 
105
110
  "search.empty.exits_title": "Prueba a ampliar la búsqueda",
106
111
  "search.empty.up_a_level": "Subir un nivel",
package/src/i18n/keys.ts CHANGED
@@ -138,6 +138,22 @@ export const SEARCH_I18N_KEYS = {
138
138
  * tail is longer than one row. */
139
139
  facetsShowAll: "search.facets.show_all",
140
140
  facetsShowLess: "search.facets.show_less",
141
+ /** The box that searches the FILTER PANEL itself — drawn from six groups
142
+ * up, because a panel a person can no longer scan is a panel they have to
143
+ * query. It narrows presentation only and never touches the URL. */
144
+ facetsSearch: "search.facets.search",
145
+ /** Said where the groups were when the panel-search matches none of them.
146
+ * Distinct from `facetsEmpty`, which says the SEARCH offers no filters —
147
+ * here the filters exist and the query missed. */
148
+ facetsSearchEmpty: "search.facets.search_empty",
149
+ /**
150
+ * The rail's sticky footer: what the applied filters LEFT — "N listings
151
+ * match". A PLURAL FAMILY: it counts a noun in words. Desktop filters
152
+ * apply instantly, so this is feedback, not an apply button; the floor
153
+ * case (`at_least`) reuses `resultsCountAtLeast` rather than growing a
154
+ * second family for the same number.
155
+ */
156
+ facetsMatchCount: "search.facets.match_count",
141
157
 
142
158
  // ── the filter panel as a whole (phone sheet + host slots) ───────────────
143
159
  filtersOpen: "search.filters.open",
@@ -161,6 +177,9 @@ export const SEARCH_I18N_KEYS = {
161
177
  /** A chip filtering on more than one value: "Bosch, +2". Not a plural
162
178
  * family — nothing is being counted in words. */
163
179
  filtersChipMore: "search.filters.chip_more",
180
+ /** The chip standing in for the row's capped tail — opens the full panel.
181
+ * `{count}` is how many chips it hides (D16). */
182
+ filtersChipOverflow: "search.filters.chips_overflow",
164
183
 
165
184
  // ── the way OUT of a search that found nothing ───────────────────────────
166
185
  /** Caption above the exit buttons. Not a second "nothing found" — that
@@ -308,6 +327,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
308
327
  SEARCH_I18N_KEYS.filtersShowCount,
309
328
  SEARCH_I18N_KEYS.filtersShowCountAtLeast,
310
329
  SEARCH_I18N_KEYS.boxCategoryCount,
330
+ SEARCH_I18N_KEYS.facetsMatchCount,
311
331
  ];
312
332
 
313
333
  /**
@@ -392,6 +412,10 @@ export const searchI18nBundleEn: Record<string, string> = {
392
412
  "search.facets.range_to_aria": "{feature}, up to",
393
413
  "search.facets.show_all": "Show all ({count})",
394
414
  "search.facets.show_less": "Show fewer",
415
+ "search.facets.search": "Find a filter",
416
+ "search.facets.search_empty": "No filter matches this",
417
+ "search.facets.match_count.one": "{count} listing matches",
418
+ "search.facets.match_count.other": "{count} listings match",
395
419
  "search.facets.range_invalid":
396
420
  "“From” is larger than “to”, so nothing could match. Swap them to apply this range.",
397
421
 
@@ -406,6 +430,7 @@ export const searchI18nBundleEn: Record<string, string> = {
406
430
  "search.filters.all": "All filters",
407
431
  "search.filters.short": "Filters",
408
432
  "search.filters.chip_more": ", +{count}",
433
+ "search.filters.chips_overflow": "More · {count}",
409
434
 
410
435
  "search.empty.exits_title": "Try widening the search",
411
436
  "search.empty.up_a_level": "Go up a level",
package/src/i18n/ru.ts CHANGED
@@ -112,6 +112,12 @@ export const searchI18nBundleRu: I18nDictionary = {
112
112
  "search.facets.range_to_aria": "{feature}, до",
113
113
  "search.facets.show_all": "Показать все ({count})",
114
114
  "search.facets.show_less": "Свернуть",
115
+ "search.facets.search": "Найти фильтр",
116
+ "search.facets.search_empty": "Среди фильтров такого нет",
117
+ "search.facets.match_count.one": "Найдено {count} объявление",
118
+ "search.facets.match_count.few": "Найдено {count} объявления",
119
+ "search.facets.match_count.many": "Найдено {count} объявлений",
120
+ "search.facets.match_count.other": "Найдено {count} объявления",
115
121
  "search.facets.range_invalid":
116
122
  "«От» больше, чем «до», — под такой диапазон ничего не подойдёт. Поменяйте значения местами.",
117
123
 
@@ -130,6 +136,7 @@ export const searchI18nBundleRu: I18nDictionary = {
130
136
  "search.filters.all": "Все фильтры",
131
137
  "search.filters.short": "Фильтры",
132
138
  "search.filters.chip_more": ", +{count}",
139
+ "search.filters.chips_overflow": "Ещё {count}",
133
140
 
134
141
  "search.empty.exits_title": "Попробуйте расширить поиск",
135
142
  "search.empty.up_a_level": "Подняться на уровень выше",
@@ -128,6 +128,25 @@ export interface FacetGroup {
128
128
  readonly selected: readonly string[];
129
129
  }
130
130
 
131
+ /**
132
+ * How many candidate documents carry ANY value of this axis — the evidence
133
+ * that people actually fill this field in. Uncounted options are `null`
134
+ * (never `0` — different sentences) and contribute nothing, so a group the
135
+ * server did not count sums to zero.
136
+ *
137
+ * Two surfaces rank by it, for the same reason: the chip row orders its
138
+ * counted band by coverage (D16 reopen — an imported catalogue gave the
139
+ * phones leaf option tables for its wholesale plumbing, and schema order put
140
+ * them ahead of the brand), and the filter panel opens its top-coverage
141
+ * groups and collapses the rest (the 5717px rail). One definition, or the
142
+ * two surfaces drift into two opinions about which axes matter.
143
+ */
144
+ export function facetCoverage(group: FacetGroup): number {
145
+ let total = 0;
146
+ for (const option of group.options) total += option.count ?? 0;
147
+ return total;
148
+ }
149
+
131
150
  export interface BuildFacetGroupsInput {
132
151
  /** The envelope's `facets`: `{slug: {value: count}}`. */
133
152
  readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;