@stapel/search-react 0.31.0 → 0.32.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 (89) hide show
  1. package/CHANGELOG.md +82 -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 +16 -0
  13. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  14. package/dist/default/FacetPanelPane.js +94 -19
  15. package/dist/default/FacetPanelPane.js.map +1 -1
  16. package/dist/default/FilterChips.d.ts +22 -0
  17. package/dist/default/FilterChips.d.ts.map +1 -1
  18. package/dist/default/FilterChips.js +31 -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 +55 -3
  65. package/dist/state/ranges.d.ts.map +1 -1
  66. package/dist/state/ranges.js +78 -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 +240 -124
  77. package/src/default/FilterChips.tsx +39 -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 +158 -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
  /**
@@ -231,6 +244,22 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
231
244
  /** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
232
245
  * to a path). Unfilled, an active category still gets a "clear" control. */
233
246
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
247
+ /**
248
+ * Draw the "Category" pane at all. Default `true`.
249
+ *
250
+ * `false` removes it ENTIRELY — no cascade, no development placeholder, and
251
+ * in particular no raw-id fallback: the arm that prints "Category: 32/149"
252
+ * with a "clear" button under it goes with the rest. That fallback exists so
253
+ * a shared link narrowing to a category always carries the control that
254
+ * widens it again, and it is the right default. It is the wrong thing on a
255
+ * surface whose category IS the page — a catalogue leaf reached by walking
256
+ * the tree, where the tree is the navigation above the panel and the id path
257
+ * inside the filters is machine state printed at a shopper.
258
+ *
259
+ * A host turning it off owns the way back out: on such a page it is the
260
+ * breadcrumb, or the tiles the reader arrived through.
261
+ */
262
+ readonly categoryFilter?: boolean;
234
263
  /** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
235
264
  readonly languages?: readonly string[];
236
265
  /**
@@ -545,6 +574,20 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
545
574
  // panel's own search box: the URL is the search, and how much of the rail a
546
575
  // person has unfolded is not part of it.
547
576
  const [tailOpen, setTailOpen] = useState(false);
577
+ /**
578
+ * The range drafts the rows have reported and nobody has applied yet.
579
+ *
580
+ * The panel owns ONE "Apply" for every from/to row it draws — see
581
+ * `RangeFilterRow`'s module note — so it also owns what those rows have
582
+ * typed. Keyed by slug, emptied by an apply (the rows then re-read the URL
583
+ * they just wrote) and by a clear.
584
+ */
585
+ const [rangeDrafts, setRangeDrafts] = useState<
586
+ Readonly<Record<string, RangeDraft>>
587
+ >({});
588
+ const noteDraft = (slug: string, draft: RangeDraft): void => {
589
+ setRangeDrafts((was) => ({ ...was, [slug]: draft }));
590
+ };
548
591
  // `true` is the shape the prop shipped with and keeps meaning: pinned.
549
592
  const footerBar: "sticky" | "static" | "none" =
550
593
  props.footerBar === true
@@ -579,10 +622,15 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
579
622
  ? { categoryFeatures: props.categoryFeatures }
580
623
  : {}),
581
624
  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).
625
+ // The measured ends, the CAPTIONS (0.16.0), and the axes the
626
+ // schema types as choices — a vocabulary-backed year is a from/to
627
+ // here because the answer says it has numbers behind it
628
+ // (stapel-search 0.14.7).
585
629
  ...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
630
+ // And the axes this answer planned and declined to offer, so the
631
+ // schema cannot put back a sparse or unnameable row the server
632
+ // just removed (0.16.0).
633
+ withheld: bag.withheld,
586
634
  ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
587
635
  t,
588
636
  });
@@ -608,6 +656,67 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
608
656
  props.partition !== undefined ||
609
657
  coreRanges.length > 0 ||
610
658
  state.geo !== undefined;
659
+ /**
660
+ * Apply every pending draft at once — the panel's own button.
661
+ *
662
+ * Only the DIRTY ones are sent: an Apply over three rows where one
663
+ * was typed into is one request, not three, and the two untouched
664
+ * rows must not rewrite the URL with the values it already carries.
665
+ * An unusable draft (`100..50`) blocks the button rather than being
666
+ * skipped quietly, which is the same refusal the row used to make
667
+ * for itself.
668
+ */
669
+ const pending = Object.entries(rangeDrafts).filter(
670
+ ([, draft]) => draft.dirty
671
+ );
672
+ const rangesUsable = pending.every(([, draft]) => draft.usable);
673
+ const applyRanges = (): void => {
674
+ if (!rangesUsable) return;
675
+ // ONE commit. `setRange` per axis would fold every edit into the
676
+ // same starting state and apply only the last of them — see
677
+ // `SearchStateBag.setRanges`.
678
+ bag.setRanges(Object.fromEntries(
679
+ pending.map(([slug, draft]) => [slug, draft.range])
680
+ ));
681
+ setRangeDrafts({});
682
+ };
683
+ const rangesApply: ActionAvailability = rangesUsable
684
+ ? actionAvailable()
685
+ : actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
686
+ /** One from/to row, reporting to the button above rather than
687
+ * carrying one of its own. */
688
+ const rangeRow = (group: RangeGroup): ReactElement => (
689
+ <RangeFilterRow
690
+ key={group.slug}
691
+ group={group}
692
+ onApply={bag.setRange}
693
+ onDraft={noteDraft}
694
+ onCommit={applyRanges}
695
+ />
696
+ );
697
+ /**
698
+ * The panel's single "Apply", under the rows it commits.
699
+ *
700
+ * Drawn only where there is a from/to row to commit, primary only
701
+ * once something is actually pending — a filled button over three
702
+ * untouched fields is a button that does nothing, and the rail used
703
+ * to draw one of those per row.
704
+ */
705
+ const rangesApplyButton = (rows: readonly RangeGroup[]): ReactNode =>
706
+ rows.length === 0 ? null : (
707
+ <GatedButton
708
+ gate={rangesApply}
709
+ type={pending.length > 0 ? "primary" : "default"}
710
+ style={{ alignSelf: "flex-start" }}
711
+ testId="facet-ranges-apply"
712
+ data-pending={pending.length}
713
+ data-analytics="none"
714
+ data-analytics-reason="a filter is a read, not a flow step"
715
+ onClick={applyRanges}
716
+ >
717
+ {t(SEARCH_I18N_KEYS.facetsRangeApply)}
718
+ </GatedButton>
719
+ );
611
720
  return (
612
721
  <Flex vertical gap={spacing[3]} data-testid="search-facets">
613
722
  {/* In a 280px rail this row laid the word "Filters" out in a
@@ -654,40 +763,21 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
654
763
  <div data-testid="search-partition">{props.partition}</div>
655
764
  )}
656
765
 
657
- <CategoryFilter
658
- {...(props.renderCategoryFilter !== undefined
659
- ? { render: props.renderCategoryFilter }
660
- : {})}
661
- />
766
+ {/* The "Category" pane, unless the surface has said its category
767
+ is the page rather than a filter over it — see
768
+ `FacetPanelPaneProps.categoryFilter`. Off means OFF: no
769
+ placeholder, and no raw-id line either. */}
770
+ {props.categoryFilter !== false && (
771
+ <CategoryFilter
772
+ {...(props.renderCategoryFilter !== undefined
773
+ ? { render: props.renderCategoryFilter }
774
+ : {})}
775
+ />
776
+ )}
662
777
  <LanguageSelect
663
778
  {...(props.languages !== undefined ? { languages: props.languages } : {})}
664
779
  />
665
780
 
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
781
  {/* Honesty flags, not failures: the counts ARE approximate and
692
782
  those slugs WERE skipped, and a red box would teach a person
693
783
  the page is broken. `ErrorAlert` is for a read that did not
@@ -711,10 +801,68 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
711
801
  />
712
802
  )}
713
803
 
804
+ {/* NO GROUPS, BUT STILL A PANEL. `LoadList` draws its `empty` arm
805
+ instead of the children, so the from/to rows would vanish with
806
+ the checkboxes on a leaf whose only filter is a price — the one
807
+ shape where the rail is nothing else. Drawn here, in the place
808
+ the merged list would have put them. */}
809
+ {bag.state.status === "ready" &&
810
+ bag.state.data.length === 0 &&
811
+ ranges.length > 0 && (
812
+ <Flex vertical gap={spacing[3]} data-testid="search-ranges">
813
+ {ranges.map(rangeRow)}
814
+ {rangesApplyButton(ranges)}
815
+ </Flex>
816
+ )}
817
+
818
+ {/* ONE BOX WHILE THE PLAN IS UNKNOWN (0.34 CLS on a live host).
819
+ The numeric band and its reservation used to be SIBLINGS of
820
+ this arm: the rail drew four skeleton rows, then a divider,
821
+ then N range skeletons under it, and when the answer landed the
822
+ rows moved up into the order the plan asked for — every one of
823
+ them travelling past the reader's eye. The loading arm now
824
+ covers the whole pane, so nothing renders after it until there
825
+ is an order to render in.
826
+
827
+ Two things are unknown at first paint and each gets its own
828
+ reserve inside the box:
829
+ - the SCHEMA (`categoryFeatures` undefined) — the axis count
830
+ is unknown, so one row's floor is the guess;
831
+ - the ANSWER with a known schema — the count is certain from
832
+ the schema (and, better, from what an earlier answer FOR
833
+ THIS CATEGORY measured: `bag.reservedRangeAxes`), so that
834
+ many skeleton rows, each `RANGE_ROW_MIN_HEIGHT` tall like
835
+ the row it will become. */}
714
836
  <LoadList
715
837
  state={bag.state}
716
838
  testId="facets"
717
839
  skeletonRows={4}
840
+ loading={
841
+ <Flex vertical gap={spacing[3]}>
842
+ <Skeleton active paragraph={{ rows: 4 }} />
843
+ <Divider style={{ margin: 0 }} />
844
+ {props.categoryFeatures === undefined ? (
845
+ <div
846
+ aria-hidden="true"
847
+ data-testid="search-ranges-attributes-reserve"
848
+ style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
849
+ />
850
+ ) : (
851
+ <Flex
852
+ vertical
853
+ gap={spacing[3]}
854
+ data-testid="search-ranges-attributes"
855
+ >
856
+ {Array.from(
857
+ { length: Math.max(reservedAxes, 1) },
858
+ (_, index) => (
859
+ <RangeRowSkeleton key={index} />
860
+ )
861
+ )}
862
+ </Flex>
863
+ )}
864
+ </Flex>
865
+ }
718
866
  empty={<FacetsEmptyArm bag={bag} hasOtherDrawable={hasOtherDrawable} />}
719
867
  failed={(error) => (
720
868
  <ErrorAlert
@@ -740,7 +888,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
740
888
  // narrows a catalogue in. Groups the schema does not name
741
889
  // keep evidence order among themselves.
742
890
  const drawable = orderFacetGroupsBySchema({
743
- groups: groups.filter(facetGroupIsDrawable),
891
+ groups: groups
892
+ .filter(facetGroupIsDrawable)
893
+ // A bucket list with no buckets is a caption and an empty
894
+ // box. Dropped HERE and not only inside the control, so
895
+ // the count in "All filters (K)", the panel search and the
896
+ // fold all agree about what is on the rail.
897
+ .filter((group) => !facetGroupIsEmptyHeading(group)),
744
898
  ...(props.categoryFeatures !== undefined
745
899
  ? { categoryFeatures: props.categoryFeatures }
746
900
  : {}),
@@ -771,6 +925,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
771
925
  );
772
926
  const listed =
773
927
  needle === "" ? drawable : drawable.filter(matches);
928
+ // The panel search narrows the from/to rows too: a person who
929
+ // typed "price" is looking for the price picker as much as for
930
+ // a checkbox group, and leaving the rows in would answer the
931
+ // query with everything it did not match.
932
+ const listedRanges =
933
+ needle === ""
934
+ ? ranges
935
+ : ranges.filter((group) =>
936
+ group.label.toLowerCase().includes(needle)
937
+ );
774
938
  // The tail. Only while nothing is typed: a query has already
775
939
  // narrowed the list, and folding its answer would hide the
776
940
  // thing that was looked for. One group over the limit is not
@@ -828,29 +992,52 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
828
992
  not decide — `facetGroupShape` reads the same config keys
829
993
  the attributes editor reads, so a facet cannot look one
830
994
  way here and another way in the composer. */}
995
+ {/* ONE PANEL, ONE ORDER. The bucket lists and the from/to
996
+ pickers are numbered on the same scale by the plan
997
+ (stapel-search 0.16.0), so they are drawn as one
998
+ sequence: "Price" and "Year" land where the category
999
+ authored them instead of above and below every choice.
1000
+ A host's `pinnedFacets` reaches both halves — see
1001
+ `orderPanelItems`, which also holds the band order used
1002
+ when nobody numbered anything. */}
831
1003
  {/* The key changes with the query's presence ON PURPOSE:
832
1004
  `defaultOpen` is an initial value, and a group the
833
1005
  panel-search matched has to render OPEN — a hit behind
834
1006
  a closed header is not an answer. Remounting is the
835
1007
  honest way to re-ask the question; the person's own
836
1008
  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
- ))}
1009
+ {orderPanelItems({
1010
+ groups: shownGroups,
1011
+ ranges: listedRanges,
1012
+ ...(props.pinnedFacets !== undefined
1013
+ ? { pinned: props.pinnedFacets }
1014
+ : {}),
1015
+ }).map((item: PanelItem) =>
1016
+ item.kind === "range" ? (
1017
+ rangeRow(item.range)
1018
+ ) : (
1019
+ <FacetGroupControl
1020
+ key={
1021
+ needle === ""
1022
+ ? item.group.slug
1023
+ : `${item.group.slug}:match`
1024
+ }
1025
+ group={item.group}
1026
+ onToggle={bag.toggle}
1027
+ onSetValues={bag.setValues}
1028
+ collapsible
1029
+ defaultOpen={
1030
+ needle !== "" ||
1031
+ item.group.selected.length > 0 ||
1032
+ openByOrder.has(item.group.slug)
1033
+ }
1034
+ {...(props.dictionaryMode !== undefined
1035
+ ? { dictionaryMode: props.dictionaryMode }
1036
+ : {})}
1037
+ />
1038
+ )
1039
+ )}
1040
+ {rangesApplyButton(listedRanges)}
854
1041
  {tailFolded && (
855
1042
  <Button
856
1043
  style={{ alignSelf: "flex-start" }}
@@ -876,77 +1063,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
876
1063
  }}
877
1064
  </LoadList>
878
1065
 
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
1066
  {footerBar !== "none" && (
951
1067
  <RailFooterBar
952
1068
  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
@@ -559,7 +582,22 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
559
582
  * keeps its own door.
560
583
  */
561
584
  const hasChips = showCategoryChip || ordered.length > 0;
562
- if (!hasChips) return null;
585
+ if (!hasChips) {
586
+ // THE BOX THE ROW WILL ARRIVE INTO — see `CHIP_ROW_MIN_HEIGHT`. Only
587
+ // while the answer is still in flight and this search is one a row is
588
+ // predictable for; a settled answer with no chips renders nothing, which
589
+ // is what it has always done and what it should do.
590
+ const expected =
591
+ bag.state.status === "loading" &&
592
+ ((props.categoryFeatures?.length ?? 0) > 0 || state.category !== undefined);
593
+ return expected ? (
594
+ <div
595
+ aria-hidden="true"
596
+ data-testid="search-filter-chips-reserve"
597
+ style={{ minBlockSize: CHIP_ROW_MIN_HEIGHT }}
598
+ />
599
+ ) : null;
600
+ }
563
601
 
564
602
  return (
565
603
  <>
@@ -29,7 +29,7 @@
29
29
  import type { CSSProperties, ReactElement, ReactNode } from "react";
30
30
  import { Button, Flex, Typography } from "antd";
31
31
  import { useT } from "@stapel/core";
32
- import { spacing } from "@stapel/tokens";
32
+ import { breakpoints, spacing } from "@stapel/tokens";
33
33
  import type { FacetGroup, FacetOption } from "../state/facets.js";
34
34
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
35
35
 
@@ -40,6 +40,66 @@ export const POPULAR_VALUES_LIMIT = 12;
40
40
  * without turning a make into a two-line wrap. */
41
41
  export const POPULAR_VALUES_COLUMNS = 3;
42
42
 
43
+ /**
44
+ * The width one column of this block needs: a make and its count, on one line,
45
+ * at the default type step. «Ford 1 204» is the measure; below it a value
46
+ * wraps under its own number and the block stops being scannable.
47
+ */
48
+ export const POPULAR_VALUE_COLUMN_WIDTH = 200;
49
+
50
+ /** The class the container query is hung on. */
51
+ export const POPULAR_VALUES_CLASS = "stapel-popular-values";
52
+
53
+ /** The `href` the hoisted ladder sheet is deduplicated by (React 19). */
54
+ export const POPULAR_VALUES_STYLE_HREF = "stapel-popular-values";
55
+
56
+ /**
57
+ * The ladder `columns="responsive"` climbs — one rung per column, by the width
58
+ * of the BLOCK rather than of the window.
59
+ *
60
+ * The container is what decides, and it has to be: this block sits in the
61
+ * results column, which on a 1440px desktop is the window minus a 280px rail
62
+ * minus the gap. A media query would give it four columns at a width it never
63
+ * has, and one column inside a narrow host container that happens to sit on a
64
+ * wide screen. `container-type: inline-size` plus `@container` asks the only
65
+ * question that has an answer here — how wide is this block.
66
+ *
67
+ * The rungs: one column while the block is narrower than two of
68
+ * {@link POPULAR_VALUE_COLUMN_WIDTH}, then the two token breakpoints for the
69
+ * third and fourth. `tablet` and `desktop` are `@stapel/tokens`' own numbers,
70
+ * and using them here is the same statement they make everywhere else — this
71
+ * is where a layout of this density gains a column.
72
+ */
73
+ export const POPULAR_VALUES_LADDER: readonly {
74
+ readonly minInlineSize: number;
75
+ readonly columns: number;
76
+ }[] = [
77
+ { minInlineSize: POPULAR_VALUE_COLUMN_WIDTH * 2, columns: 2 },
78
+ { minInlineSize: breakpoints.tablet, columns: 3 },
79
+ { minInlineSize: breakpoints.desktop, columns: 4 },
80
+ ];
81
+
82
+ /**
83
+ * The ladder as CSS. One `@container` rule per rung, ascending, so the widest
84
+ * matching rung is the one that wins by ordinary cascade order.
85
+ *
86
+ * Emitted as a hoisted `<style>` rather than an inline style because a
87
+ * container query is unreachable from one — the same reason `<SearchPage>`
88
+ * hoists its rail sheet.
89
+ */
90
+ export function popularValuesLadderCss(): string {
91
+ const block = `.${POPULAR_VALUES_CLASS}`;
92
+ return [
93
+ `${block}{container-type:inline-size}`,
94
+ `${block}>[data-popular-columns]{column-count:1}`,
95
+ ...POPULAR_VALUES_LADDER.map(
96
+ (rung) =>
97
+ `@container (min-width: ${String(rung.minInlineSize)}px)` +
98
+ `{${block}>[data-popular-columns]{column-count:${String(rung.columns)}}}`
99
+ ),
100
+ ].join("\n");
101
+ }
102
+
43
103
  /** A value with no evidence behind it is not a popular value. Uncounted
44
104
  * options carry `count: null` and are dropped here rather than printed with a
45
105
  * blank where the number belongs — the block IS the numbers. */
@@ -76,8 +136,15 @@ export interface PopularValuesProps {
76
136
  readonly hidden?: boolean;
77
137
  /** How many values. Default {@link POPULAR_VALUES_LIMIT}. */
78
138
  readonly limit?: number;
79
- /** How many columns. Default {@link POPULAR_VALUES_COLUMNS}. */
80
- readonly columns?: number;
139
+ /**
140
+ * How many columns. Default {@link POPULAR_VALUES_COLUMNS}.
141
+ *
142
+ * `"responsive"` climbs {@link POPULAR_VALUES_LADDER} by the width of the
143
+ * BLOCK — 1, 2, 3, 4 — instead of taking one number for every surface. The
144
+ * numeric form stays, and stays the default: a host that has already decided
145
+ * its layout should not have that decision taken back by a query.
146
+ */
147
+ readonly columns?: number | "responsive";
81
148
  /** The block's heading. Defaults to the group's own label; `null` draws
82
149
  * none, for a surface that has already named the axis. */
83
150
  readonly heading?: ReactNode;
@@ -93,19 +160,36 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
93
160
  const options = popularOptions(group, props.limit ?? POPULAR_VALUES_LIMIT);
94
161
  if (options.length === 0) return null;
95
162
 
163
+ const responsive = props.columns === "responsive";
164
+
96
165
  return (
97
166
  <Flex
98
167
  vertical
99
168
  gap={spacing[2]}
169
+ {...(responsive ? { className: POPULAR_VALUES_CLASS } : {})}
100
170
  data-testid={`popular-values-${group.slug}`}
101
171
  data-label-source={group.labelSource}
172
+ data-columns={responsive ? "responsive" : String(props.columns ?? POPULAR_VALUES_COLUMNS)}
102
173
  >
174
+ {/* The ladder, hoisted and deduped by `href`: a container query has no
175
+ inline form. Only where it is asked for — a block with a fixed
176
+ column count needs no sheet at all. */}
177
+ {responsive && (
178
+ <style href={POPULAR_VALUES_STYLE_HREF} precedence="default">
179
+ {popularValuesLadderCss()}
180
+ </style>
181
+ )}
103
182
  {props.heading !== null && (
104
183
  <Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
105
184
  )}
106
185
  <div
186
+ {...(responsive ? { "data-popular-columns": "" } : {})}
107
187
  style={{
108
- columnCount: props.columns ?? POPULAR_VALUES_COLUMNS,
188
+ // The rungs live in the sheet above; an inline `column-count` here
189
+ // would win against every one of them.
190
+ ...(responsive
191
+ ? {}
192
+ : { columnCount: props.columns ?? POPULAR_VALUES_COLUMNS }),
109
193
  columnGap: spacing[4],
110
194
  }}
111
195
  >