@stapel/search-react 0.31.0 → 0.32.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 (89) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/api/generated/schema.d.ts +69 -6
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +118 -33
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js +13 -0
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/FacetGroupControl.d.ts +18 -1
  9. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  10. package/dist/default/FacetGroupControl.js +25 -0
  11. package/dist/default/FacetGroupControl.js.map +1 -1
  12. package/dist/default/FacetPanelPane.d.ts +27 -0
  13. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  14. package/dist/default/FacetPanelPane.js +100 -19
  15. package/dist/default/FacetPanelPane.js.map +1 -1
  16. package/dist/default/FilterChips.d.ts +30 -0
  17. package/dist/default/FilterChips.d.ts.map +1 -1
  18. package/dist/default/FilterChips.js +35 -2
  19. package/dist/default/FilterChips.js.map +1 -1
  20. package/dist/default/PopularValues.d.ts +49 -2
  21. package/dist/default/PopularValues.d.ts.map +1 -1
  22. package/dist/default/PopularValues.js +57 -3
  23. package/dist/default/PopularValues.js.map +1 -1
  24. package/dist/default/RangeFilterRow.d.ts +25 -0
  25. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  26. package/dist/default/RangeFilterRow.js +77 -5
  27. package/dist/default/RangeFilterRow.js.map +1 -1
  28. package/dist/default/SearchPage.d.ts +28 -1
  29. package/dist/default/SearchPage.d.ts.map +1 -1
  30. package/dist/default/SearchPage.js +13 -6
  31. package/dist/default/SearchPage.js.map +1 -1
  32. package/dist/default/SearchResultsPane.d.ts +10 -0
  33. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  34. package/dist/default/SearchResultsPane.js +1 -1
  35. package/dist/default/SearchResultsPane.js.map +1 -1
  36. package/dist/default/SortSelect.d.ts +10 -10
  37. package/dist/default/SortSelect.d.ts.map +1 -1
  38. package/dist/default/SortSelect.js +19 -8
  39. package/dist/default/SortSelect.js.map +1 -1
  40. package/dist/default/index.d.ts +4 -4
  41. package/dist/default/index.d.ts.map +1 -1
  42. package/dist/default/index.js +3 -3
  43. package/dist/default/index.js.map +1 -1
  44. package/dist/headless/FacetPanel.d.ts +7 -0
  45. package/dist/headless/FacetPanel.d.ts.map +1 -1
  46. package/dist/headless/FacetPanel.js +2 -1
  47. package/dist/headless/FacetPanel.js.map +1 -1
  48. package/dist/headless/SearchStateProvider.d.ts +15 -0
  49. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  50. package/dist/headless/SearchStateProvider.js +0 -0
  51. package/dist/headless/SearchStateProvider.js.map +1 -1
  52. package/dist/index.d.ts +4 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/state/facets.d.ts +13 -0
  57. package/dist/state/facets.d.ts.map +1 -1
  58. package/dist/state/facets.js +13 -0
  59. package/dist/state/facets.js.map +1 -1
  60. package/dist/state/panel.d.ts +79 -0
  61. package/dist/state/panel.d.ts.map +1 -0
  62. package/dist/state/panel.js +75 -0
  63. package/dist/state/panel.js.map +1 -0
  64. package/dist/state/ranges.d.ts +88 -3
  65. package/dist/state/ranges.d.ts.map +1 -1
  66. package/dist/state/ranges.js +96 -20
  67. package/dist/state/ranges.js.map +1 -1
  68. package/llms.txt +4 -4
  69. package/manifest.json +12 -2
  70. package/nav-manifest.json +1 -1
  71. package/package.json +9 -9
  72. package/src/analytics/generated/events.json +1 -1
  73. package/src/api/generated/schema.ts +69 -6
  74. package/src/api/types.ts +132 -32
  75. package/src/default/FacetGroupControl.tsx +29 -1
  76. package/src/default/FacetPanelPane.tsx +257 -124
  77. package/src/default/FilterChips.tsx +51 -1
  78. package/src/default/PopularValues.tsx +88 -4
  79. package/src/default/RangeFilterRow.tsx +117 -20
  80. package/src/default/SearchPage.tsx +46 -4
  81. package/src/default/SearchResultsPane.tsx +13 -0
  82. package/src/default/SortSelect.tsx +29 -19
  83. package/src/default/index.ts +8 -1
  84. package/src/headless/FacetPanel.tsx +17 -2
  85. package/src/headless/SearchStateProvider.tsx +23 -0
  86. package/src/index.ts +8 -0
  87. package/src/state/facets.ts +27 -0
  88. package/src/state/panel.ts +138 -0
  89. package/src/state/ranges.ts +209 -47
@@ -76,13 +76,22 @@ import {
76
76
  Divider,
77
77
  Flex,
78
78
  Input,
79
+ Skeleton,
79
80
  Typography,
80
81
  theme,
81
82
  } from "antd";
82
- import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
83
+ import {
84
+ SlotPlaceholder,
85
+ actionAvailable,
86
+ actionBlocked,
87
+ useT,
88
+ useTPlural,
89
+ } from "@stapel/core";
90
+ import type { ActionAvailability } from "@stapel/core";
83
91
  import {
84
92
  EmptyState,
85
93
  ErrorAlert,
94
+ GatedButton,
86
95
  LoadList,
87
96
  SkinTheme,
88
97
  } from "@stapel/tokens-antd/skin";
@@ -101,11 +110,15 @@ import {
101
110
  orderFacetGroupsBySchema,
102
111
  } from "../state/facets.js";
103
112
  import type { FacetGroup } from "../state/facets.js";
104
- import { FacetGroupControl } from "./FacetGroupControl.js";
113
+ import { FacetGroupControl, facetGroupIsEmptyHeading } from "./FacetGroupControl.js";
105
114
  import { buildRangeGroups } from "../state/ranges.js";
115
+ import type { RangeGroup } from "../state/ranges.js";
116
+ import { orderPanelItems } from "../state/panel.js";
117
+ import type { PanelItem } from "../state/panel.js";
106
118
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
107
119
  import { LanguageSelect } from "./LanguageSelect.js";
108
120
  import { RANGE_ROW_MIN_HEIGHT, RangeFilterRow, RangeRowSkeleton } from "./RangeFilterRow.js";
121
+ import type { RangeDraft } from "./RangeFilterRow.js";
109
122
  import type { ThemeModeProp } from "./types.js";
110
123
 
111
124
  /**
@@ -228,9 +241,36 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
228
241
  * and the chip row cannot print two different words for one value.
229
242
  */
230
243
  readonly resolveFacetLabels?: FacetLabelResolver;
244
+ /**
245
+ * Draw BOTH a bucket list and a from/to picker for a slug the answer
246
+ * counted, instead of leaving the axis to its group.
247
+ *
248
+ * Default `false`, which is the fix: an imported `year` is a vocabulary
249
+ * choice in the catalogue and a measurement in the plan, and the rail used
250
+ * to draw a checkbox group of years and a from/to over the same field
251
+ * further down — two controls writing one filter, neither showing what the
252
+ * other did. See `BuildRangeGroupsInput.countedFacets`.
253
+ */
254
+ readonly bothAxes?: boolean;
231
255
  /** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
232
256
  * to a path). Unfilled, an active category still gets a "clear" control. */
233
257
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
258
+ /**
259
+ * Draw the "Category" pane at all. Default `true`.
260
+ *
261
+ * `false` removes it ENTIRELY — no cascade, no development placeholder, and
262
+ * in particular no raw-id fallback: the arm that prints "Category: 32/149"
263
+ * with a "clear" button under it goes with the rest. That fallback exists so
264
+ * a shared link narrowing to a category always carries the control that
265
+ * widens it again, and it is the right default. It is the wrong thing on a
266
+ * surface whose category IS the page — a catalogue leaf reached by walking
267
+ * the tree, where the tree is the navigation above the panel and the id path
268
+ * inside the filters is machine state printed at a shopper.
269
+ *
270
+ * A host turning it off owns the way back out: on such a page it is the
271
+ * breadcrumb, or the tiles the reader arrived through.
272
+ */
273
+ readonly categoryFilter?: boolean;
234
274
  /** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
235
275
  readonly languages?: readonly string[];
236
276
  /**
@@ -545,6 +585,20 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
545
585
  // panel's own search box: the URL is the search, and how much of the rail a
546
586
  // person has unfolded is not part of it.
547
587
  const [tailOpen, setTailOpen] = useState(false);
588
+ /**
589
+ * The range drafts the rows have reported and nobody has applied yet.
590
+ *
591
+ * The panel owns ONE "Apply" for every from/to row it draws — see
592
+ * `RangeFilterRow`'s module note — so it also owns what those rows have
593
+ * typed. Keyed by slug, emptied by an apply (the rows then re-read the URL
594
+ * they just wrote) and by a clear.
595
+ */
596
+ const [rangeDrafts, setRangeDrafts] = useState<
597
+ Readonly<Record<string, RangeDraft>>
598
+ >({});
599
+ const noteDraft = (slug: string, draft: RangeDraft): void => {
600
+ setRangeDrafts((was) => ({ ...was, [slug]: draft }));
601
+ };
548
602
  // `true` is the shape the prop shipped with and keeps meaning: pinned.
549
603
  const footerBar: "sticky" | "static" | "none" =
550
604
  props.footerBar === true
@@ -579,10 +633,21 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
579
633
  ? { categoryFeatures: props.categoryFeatures }
580
634
  : {}),
581
635
  coreRanges: bag.coreRanges,
582
- // The measured ends, and the axes the schema types as choices —
583
- // a vocabulary-backed year is a from/to here because the answer
584
- // says it has numbers behind it (stapel-search 0.14.7).
636
+ // The measured ends, the CAPTIONS (0.16.0), and the axes the
637
+ // schema types as choices — a vocabulary-backed year is a from/to
638
+ // here because the answer says it has numbers behind it
639
+ // (stapel-search 0.14.7).
585
640
  ...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
641
+ // And the axes this answer planned and declined to offer, so the
642
+ // schema cannot put back a sparse or unnameable row the server
643
+ // just removed (0.16.0).
644
+ withheld: bag.withheld,
645
+ // ONE AXIS, ONE CONTROL. A slug the answer COUNTED already has a
646
+ // bucket list on this rail; a from/to picker over the same field
647
+ // is a second control writing the same filter. See
648
+ // `BuildRangeGroupsInput.countedFacets`.
649
+ countedFacets: bag.counted,
650
+ ...(props.bothAxes !== undefined ? { bothAxes: props.bothAxes } : {}),
586
651
  ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
587
652
  t,
588
653
  });
@@ -608,6 +673,67 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
608
673
  props.partition !== undefined ||
609
674
  coreRanges.length > 0 ||
610
675
  state.geo !== undefined;
676
+ /**
677
+ * Apply every pending draft at once — the panel's own button.
678
+ *
679
+ * Only the DIRTY ones are sent: an Apply over three rows where one
680
+ * was typed into is one request, not three, and the two untouched
681
+ * rows must not rewrite the URL with the values it already carries.
682
+ * An unusable draft (`100..50`) blocks the button rather than being
683
+ * skipped quietly, which is the same refusal the row used to make
684
+ * for itself.
685
+ */
686
+ const pending = Object.entries(rangeDrafts).filter(
687
+ ([, draft]) => draft.dirty
688
+ );
689
+ const rangesUsable = pending.every(([, draft]) => draft.usable);
690
+ const applyRanges = (): void => {
691
+ if (!rangesUsable) return;
692
+ // ONE commit. `setRange` per axis would fold every edit into the
693
+ // same starting state and apply only the last of them — see
694
+ // `SearchStateBag.setRanges`.
695
+ bag.setRanges(Object.fromEntries(
696
+ pending.map(([slug, draft]) => [slug, draft.range])
697
+ ));
698
+ setRangeDrafts({});
699
+ };
700
+ const rangesApply: ActionAvailability = rangesUsable
701
+ ? actionAvailable()
702
+ : actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
703
+ /** One from/to row, reporting to the button above rather than
704
+ * carrying one of its own. */
705
+ const rangeRow = (group: RangeGroup): ReactElement => (
706
+ <RangeFilterRow
707
+ key={group.slug}
708
+ group={group}
709
+ onApply={bag.setRange}
710
+ onDraft={noteDraft}
711
+ onCommit={applyRanges}
712
+ />
713
+ );
714
+ /**
715
+ * The panel's single "Apply", under the rows it commits.
716
+ *
717
+ * Drawn only where there is a from/to row to commit, primary only
718
+ * once something is actually pending — a filled button over three
719
+ * untouched fields is a button that does nothing, and the rail used
720
+ * to draw one of those per row.
721
+ */
722
+ const rangesApplyButton = (rows: readonly RangeGroup[]): ReactNode =>
723
+ rows.length === 0 ? null : (
724
+ <GatedButton
725
+ gate={rangesApply}
726
+ type={pending.length > 0 ? "primary" : "default"}
727
+ style={{ alignSelf: "flex-start" }}
728
+ testId="facet-ranges-apply"
729
+ data-pending={pending.length}
730
+ data-analytics="none"
731
+ data-analytics-reason="a filter is a read, not a flow step"
732
+ onClick={applyRanges}
733
+ >
734
+ {t(SEARCH_I18N_KEYS.facetsRangeApply)}
735
+ </GatedButton>
736
+ );
611
737
  return (
612
738
  <Flex vertical gap={spacing[3]} data-testid="search-facets">
613
739
  {/* In a 280px rail this row laid the word "Filters" out in a
@@ -654,40 +780,21 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
654
780
  <div data-testid="search-partition">{props.partition}</div>
655
781
  )}
656
782
 
657
- <CategoryFilter
658
- {...(props.renderCategoryFilter !== undefined
659
- ? { render: props.renderCategoryFilter }
660
- : {})}
661
- />
783
+ {/* The "Category" pane, unless the surface has said its category
784
+ is the page rather than a filter over it — see
785
+ `FacetPanelPaneProps.categoryFilter`. Off means OFF: no
786
+ placeholder, and no raw-id line either. */}
787
+ {props.categoryFilter !== false && (
788
+ <CategoryFilter
789
+ {...(props.renderCategoryFilter !== undefined
790
+ ? { render: props.renderCategoryFilter }
791
+ : {})}
792
+ />
793
+ )}
662
794
  <LanguageSelect
663
795
  {...(props.languages !== undefined ? { languages: props.languages } : {})}
664
796
  />
665
797
 
666
- {/* Price, and only the CORE axes — the ones the server declares
667
- for every document in every category.
668
-
669
- Every other numeric row is an ATTRIBUTE the category happens
670
- to declare, and on the deployed phones leaf all seven were
671
- parcel dimensions and wholesale packing. They used to render
672
- here, immediately under the price, which put 908px of
673
- shipping-weight and packing-count rows between the buyer and
674
- the brand (D120/D121).
675
- They now render AFTER the facets, which is the band order the
676
- chip row has used since D16 — see `CHIP_BAND_ORDER`. */}
677
- {coreRanges.length > 0 && (
678
- <Flex vertical gap={spacing[3]} data-testid="search-ranges">
679
- {coreRanges.map((group) => (
680
- <RangeFilterRow
681
- key={group.slug}
682
- group={group}
683
- onApply={bag.setRange}
684
- />
685
- ))}
686
- </Flex>
687
- )}
688
-
689
- {coreRanges.length > 0 && <Divider style={{ margin: 0 }} />}
690
-
691
798
  {/* Honesty flags, not failures: the counts ARE approximate and
692
799
  those slugs WERE skipped, and a red box would teach a person
693
800
  the page is broken. `ErrorAlert` is for a read that did not
@@ -711,10 +818,68 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
711
818
  />
712
819
  )}
713
820
 
821
+ {/* NO GROUPS, BUT STILL A PANEL. `LoadList` draws its `empty` arm
822
+ instead of the children, so the from/to rows would vanish with
823
+ the checkboxes on a leaf whose only filter is a price — the one
824
+ shape where the rail is nothing else. Drawn here, in the place
825
+ the merged list would have put them. */}
826
+ {bag.state.status === "ready" &&
827
+ bag.state.data.length === 0 &&
828
+ ranges.length > 0 && (
829
+ <Flex vertical gap={spacing[3]} data-testid="search-ranges">
830
+ {ranges.map(rangeRow)}
831
+ {rangesApplyButton(ranges)}
832
+ </Flex>
833
+ )}
834
+
835
+ {/* ONE BOX WHILE THE PLAN IS UNKNOWN (0.34 CLS on a live host).
836
+ The numeric band and its reservation used to be SIBLINGS of
837
+ this arm: the rail drew four skeleton rows, then a divider,
838
+ then N range skeletons under it, and when the answer landed the
839
+ rows moved up into the order the plan asked for — every one of
840
+ them travelling past the reader's eye. The loading arm now
841
+ covers the whole pane, so nothing renders after it until there
842
+ is an order to render in.
843
+
844
+ Two things are unknown at first paint and each gets its own
845
+ reserve inside the box:
846
+ - the SCHEMA (`categoryFeatures` undefined) — the axis count
847
+ is unknown, so one row's floor is the guess;
848
+ - the ANSWER with a known schema — the count is certain from
849
+ the schema (and, better, from what an earlier answer FOR
850
+ THIS CATEGORY measured: `bag.reservedRangeAxes`), so that
851
+ many skeleton rows, each `RANGE_ROW_MIN_HEIGHT` tall like
852
+ the row it will become. */}
714
853
  <LoadList
715
854
  state={bag.state}
716
855
  testId="facets"
717
856
  skeletonRows={4}
857
+ loading={
858
+ <Flex vertical gap={spacing[3]}>
859
+ <Skeleton active paragraph={{ rows: 4 }} />
860
+ <Divider style={{ margin: 0 }} />
861
+ {props.categoryFeatures === undefined ? (
862
+ <div
863
+ aria-hidden="true"
864
+ data-testid="search-ranges-attributes-reserve"
865
+ style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
866
+ />
867
+ ) : (
868
+ <Flex
869
+ vertical
870
+ gap={spacing[3]}
871
+ data-testid="search-ranges-attributes"
872
+ >
873
+ {Array.from(
874
+ { length: Math.max(reservedAxes, 1) },
875
+ (_, index) => (
876
+ <RangeRowSkeleton key={index} />
877
+ )
878
+ )}
879
+ </Flex>
880
+ )}
881
+ </Flex>
882
+ }
718
883
  empty={<FacetsEmptyArm bag={bag} hasOtherDrawable={hasOtherDrawable} />}
719
884
  failed={(error) => (
720
885
  <ErrorAlert
@@ -740,7 +905,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
740
905
  // narrows a catalogue in. Groups the schema does not name
741
906
  // keep evidence order among themselves.
742
907
  const drawable = orderFacetGroupsBySchema({
743
- groups: groups.filter(facetGroupIsDrawable),
908
+ groups: groups
909
+ .filter(facetGroupIsDrawable)
910
+ // A bucket list with no buckets is a caption and an empty
911
+ // box. Dropped HERE and not only inside the control, so
912
+ // the count in "All filters (K)", the panel search and the
913
+ // fold all agree about what is on the rail.
914
+ .filter((group) => !facetGroupIsEmptyHeading(group)),
744
915
  ...(props.categoryFeatures !== undefined
745
916
  ? { categoryFeatures: props.categoryFeatures }
746
917
  : {}),
@@ -771,6 +942,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
771
942
  );
772
943
  const listed =
773
944
  needle === "" ? drawable : drawable.filter(matches);
945
+ // The panel search narrows the from/to rows too: a person who
946
+ // typed "price" is looking for the price picker as much as for
947
+ // a checkbox group, and leaving the rows in would answer the
948
+ // query with everything it did not match.
949
+ const listedRanges =
950
+ needle === ""
951
+ ? ranges
952
+ : ranges.filter((group) =>
953
+ group.label.toLowerCase().includes(needle)
954
+ );
774
955
  // The tail. Only while nothing is typed: a query has already
775
956
  // narrowed the list, and folding its answer would hide the
776
957
  // thing that was looked for. One group over the limit is not
@@ -828,29 +1009,52 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
828
1009
  not decide — `facetGroupShape` reads the same config keys
829
1010
  the attributes editor reads, so a facet cannot look one
830
1011
  way here and another way in the composer. */}
1012
+ {/* ONE PANEL, ONE ORDER. The bucket lists and the from/to
1013
+ pickers are numbered on the same scale by the plan
1014
+ (stapel-search 0.16.0), so they are drawn as one
1015
+ sequence: "Price" and "Year" land where the category
1016
+ authored them instead of above and below every choice.
1017
+ A host's `pinnedFacets` reaches both halves — see
1018
+ `orderPanelItems`, which also holds the band order used
1019
+ when nobody numbered anything. */}
831
1020
  {/* The key changes with the query's presence ON PURPOSE:
832
1021
  `defaultOpen` is an initial value, and a group the
833
1022
  panel-search matched has to render OPEN — a hit behind
834
1023
  a closed header is not an answer. Remounting is the
835
1024
  honest way to re-ask the question; the person's own
836
1025
  opens and closes come back when the box clears. */}
837
- {shownGroups.map((group) => (
838
- <FacetGroupControl
839
- key={needle === "" ? group.slug : `${group.slug}:match`}
840
- group={group}
841
- onToggle={bag.toggle}
842
- onSetValues={bag.setValues}
843
- collapsible
844
- defaultOpen={
845
- needle !== "" ||
846
- group.selected.length > 0 ||
847
- openByOrder.has(group.slug)
848
- }
849
- {...(props.dictionaryMode !== undefined
850
- ? { dictionaryMode: props.dictionaryMode }
851
- : {})}
852
- />
853
- ))}
1026
+ {orderPanelItems({
1027
+ groups: shownGroups,
1028
+ ranges: listedRanges,
1029
+ ...(props.pinnedFacets !== undefined
1030
+ ? { pinned: props.pinnedFacets }
1031
+ : {}),
1032
+ }).map((item: PanelItem) =>
1033
+ item.kind === "range" ? (
1034
+ rangeRow(item.range)
1035
+ ) : (
1036
+ <FacetGroupControl
1037
+ key={
1038
+ needle === ""
1039
+ ? item.group.slug
1040
+ : `${item.group.slug}:match`
1041
+ }
1042
+ group={item.group}
1043
+ onToggle={bag.toggle}
1044
+ onSetValues={bag.setValues}
1045
+ collapsible
1046
+ defaultOpen={
1047
+ needle !== "" ||
1048
+ item.group.selected.length > 0 ||
1049
+ openByOrder.has(item.group.slug)
1050
+ }
1051
+ {...(props.dictionaryMode !== undefined
1052
+ ? { dictionaryMode: props.dictionaryMode }
1053
+ : {})}
1054
+ />
1055
+ )
1056
+ )}
1057
+ {rangesApplyButton(listedRanges)}
854
1058
  {tailFolded && (
855
1059
  <Button
856
1060
  style={{ alignSelf: "flex-start" }}
@@ -876,77 +1080,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
876
1080
  }}
877
1081
  </LoadList>
878
1082
 
879
- {/* The numeric tail, below the axes people actually narrow by.
880
- On the deployed mobile-phones leaf this block is 908px of
881
- battery health, four parcel dimensions and two wholesale
882
- packing counts — and while it
883
- rendered directly under the price it was the ONLY thing a
884
- buyer could see in a viewport-tall rail (D120/D121, D74 on the
885
- phone). It is a real filter for the person who wants it, so it
886
- is not deleted; it is ranked where the chip row already ranks
887
- it. */}
888
- {/* The reservation, not just the rows (D361).
889
- On a live category feed at 1536px this block's arrival was a
890
- 53px jump: `attributeRanges` draws from the CATEGORY SCHEMA
891
- (`props.categoryFeatures`), and a host that fetches the
892
- schema alongside the search answer had nothing here at all
893
- until both landed — no host slot reserved the box, so the
894
- rail grew under the reader's eye the instant it did.
895
-
896
- Two things can be unknown at first paint, and each gets its
897
- own reservation:
898
- - the SCHEMA itself (`categoryFeatures` undefined) — the
899
- axis count is unknown, so the fallback is one row's floor,
900
- a guess rather than nothing;
901
- - the ANSWER (`bag.state` not yet "ready") with a known
902
- schema — the axis COUNT is already certain from the
903
- schema, so the rail draws that many skeleton rows, each
904
- `RANGE_ROW_MIN_HEIGHT` tall like the real one it will
905
- become. Same count in both arms, so the swap from
906
- skeleton to `<RangeFilterRow>` costs no further height.
907
-
908
- And the schema is only the FIRST guess at that count. Since
909
- stapel-search 0.14.7 the answer measures the axes that have
910
- numbers behind them — including the ones the catalogue types
911
- as choices, a vocabulary-backed year — so a leaf whose schema
912
- declares two can answer with four. `bag.reservedRangeAxes` is
913
- what an earlier answer FOR THIS CATEGORY reported, remembered
914
- in the state provider; when there is one it sizes the block,
915
- because it is the count the swap will actually land on. */}
916
- {props.categoryFeatures === undefined ? (
917
- <>
918
- <Divider style={{ margin: 0 }} />
919
- <div
920
- aria-hidden="true"
921
- data-testid="search-ranges-attributes-reserve"
922
- style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
923
- />
924
- </>
925
- ) : (
926
- (attributeRanges.length > 0 || reservedAxes > 0) && (
927
- <>
928
- <Divider style={{ margin: 0 }} />
929
- <Flex
930
- vertical
931
- gap={spacing[3]}
932
- data-testid="search-ranges-attributes"
933
- >
934
- {bag.state.status === "ready"
935
- ? attributeRanges.map((group) => (
936
- <RangeFilterRow
937
- key={group.slug}
938
- group={group}
939
- onApply={bag.setRange}
940
- />
941
- ))
942
- : Array.from({ length: reservedAxes }, (_, index) => (
943
- <RangeRowSkeleton key={index} />
944
- ))}
945
- </Flex>
946
- </>
947
- )
948
- )}
949
-
950
1083
  {footerBar !== "none" && (
951
1084
  <RailFooterBar
952
1085
  activeFilters={bag.activeFilters}
@@ -307,6 +307,29 @@ export function orderChipFilters(
307
307
  */
308
308
  export const CHIP_ROW_CAP = 8;
309
309
 
310
+ /**
311
+ * The row's own block-size — one chip plus the row's padding, reserved from
312
+ * the FIRST frame wherever a row is expected.
313
+ *
314
+ * Measured on the host's phone SERP (`after-avtomobili-390-light`, 2026-09-05):
315
+ * the results block dropped ~68px the moment the chip row settled, an
316
+ * intermittent 0.045 CLS on a leaf. The row renders nothing until the answer
317
+ * lands — nothing is a correct thing to render for a search that will have no
318
+ * chips — and then it appears and pushes the first card down the page under
319
+ * the reader's eye.
320
+ *
321
+ * So the row reserves its box while the answer is IN FLIGHT AND a row is
322
+ * predictable: the surface handed a category schema, or the address names a
323
+ * category, both of which mean the plan will come back with axes. A bare text
324
+ * query with no category reserves nothing, because for that search the honest
325
+ * answer really is "no row" and reserving would be the same shift in the
326
+ * opposite direction.
327
+ *
328
+ * 44 is the phone control floor the chips themselves are sized to, plus the
329
+ * row's two `spacing[1]` bands of focus-ring room.
330
+ */
331
+ export const CHIP_ROW_MIN_HEIGHT: number = 44 + spacing[1] * 2;
332
+
310
333
  /**
311
334
  * The visible row and what the door owes: the first `max` specs — and EVERY
312
335
  * applied one, however many, because a constraint on screen must keep the
@@ -342,6 +365,14 @@ export interface FilterChipsCommonProps {
342
365
  * value cannot read one way on a chip and another way inside the sheet.
343
366
  */
344
367
  readonly resolveFacetLabels?: FacetLabelResolver;
368
+ /**
369
+ * Draw a range chip for a slug that already has a bucket-list chip.
370
+ *
371
+ * Default `false`, and the same prop the panel takes — the chip row and the
372
+ * panel must not disagree about which axes exist. See
373
+ * `BuildRangeGroupsInput.countedFacets`.
374
+ */
375
+ readonly bothAxes?: boolean;
345
376
  }
346
377
 
347
378
  export interface FilterChipsOpenerProps extends FilterChipsCommonProps {
@@ -473,6 +504,10 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
473
504
  : {}),
474
505
  coreRanges: bag.coreRanges,
475
506
  ...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
507
+ // One axis, one chip: a counted slug is already a bucket-list chip in
508
+ // this row, and a from/to chip beside it writes the same filter twice.
509
+ countedFacets: bag.counted,
510
+ ...(props.bothAxes !== undefined ? { bothAxes: props.bothAxes } : {}),
476
511
  ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
477
512
  t,
478
513
  });
@@ -559,7 +594,22 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
559
594
  * keeps its own door.
560
595
  */
561
596
  const hasChips = showCategoryChip || ordered.length > 0;
562
- if (!hasChips) return null;
597
+ if (!hasChips) {
598
+ // THE BOX THE ROW WILL ARRIVE INTO — see `CHIP_ROW_MIN_HEIGHT`. Only
599
+ // while the answer is still in flight and this search is one a row is
600
+ // predictable for; a settled answer with no chips renders nothing, which
601
+ // is what it has always done and what it should do.
602
+ const expected =
603
+ bag.state.status === "loading" &&
604
+ ((props.categoryFeatures?.length ?? 0) > 0 || state.category !== undefined);
605
+ return expected ? (
606
+ <div
607
+ aria-hidden="true"
608
+ data-testid="search-filter-chips-reserve"
609
+ style={{ minBlockSize: CHIP_ROW_MIN_HEIGHT }}
610
+ />
611
+ ) : null;
612
+ }
563
613
 
564
614
  return (
565
615
  <>