@stapel/search-react 0.33.1 → 0.35.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.
@@ -62,7 +62,7 @@
62
62
  * them visible as before: folding everything would leave a heading over
63
63
  * nothing. Chosen options are always visible, wherever their count went.
64
64
  */
65
- import { useMemo, useState } from "react";
65
+ import { useEffect, useMemo, useRef, useState } from "react";
66
66
  import type { CSSProperties, ReactElement } from "react";
67
67
  import { Button, Checkbox, Flex, Input, Typography } from "antd";
68
68
  import { useT } from "@stapel/core";
@@ -982,6 +982,94 @@ export interface FacetGroupControlProps {
982
982
  * only `dictionaryMode: "sheet"` reads it.
983
983
  */
984
984
  readonly onSetValues?: (slug: string, values: readonly string[]) => void;
985
+ /**
986
+ * A newer facet answer is in flight and what is drawn is the previous one —
987
+ * `FacetPanelBag.refreshing`.
988
+ *
989
+ * The group then RESERVES the height it was last measured at
990
+ * (`min-block-size`), so that whatever changes under it while the answer
991
+ * settles — an option count, a label the host's vocabulary resolved, an axis
992
+ * arriving with the category schema — cannot move the groups below it. It is
993
+ * a floor and never a cap: a group that needs more room still takes it.
994
+ *
995
+ * Off (the default), nothing is reserved and nothing is measured.
996
+ */
997
+ readonly refreshing?: boolean;
998
+ }
999
+
1000
+ /* ── THE DECLARED BOX (p41) ───────────────────────────────────────────────
1001
+ *
1002
+ * What a group's box is worth BEFORE anything has been measured. Four stated
1003
+ * numbers, deliberately a shade UNDER what the skin draws: the reservation is
1004
+ * a `min-block-size` floor, so a number below the real one is a no-op the
1005
+ * moment content exists, while a number above it is a hole nothing fills.
1006
+ *
1007
+ * They exist because the p43 reserve — the height a group was last measured at
1008
+ * — is only available from the SECOND answer onwards (`FacetPanelBag.
1009
+ * refreshing` is never true on a first load), and the first mount is precisely
1010
+ * the pass with nothing to stand on: the group renders at content height, and
1011
+ * every fact that lands after it (a label the host's vocabulary resolved, a
1012
+ * count, a dependent axis's options arriving with the next answer) re-lays the
1013
+ * column under the reader's eye.
1014
+ */
1015
+
1016
+ /** One option row: antd's checkbox line, without the gap between rows. */
1017
+ export const FACET_OPTION_ROW_HEIGHT = 24;
1018
+
1019
+ /** One row of `size="small"` pills in a segmented group. */
1020
+ export const FACET_PILL_ROW_HEIGHT = 24;
1021
+
1022
+ /** How many pills a 280px rail fits across before the row wraps. A stated
1023
+ * estimate — the row is `wrap`, so the true count is a function of the labels
1024
+ * — and it is used only to floor the box, never to cap it. */
1025
+ export const FACET_PILLS_PER_ROW = 3;
1026
+
1027
+ /** The group's own heading line, and the same for the fold's link button. */
1028
+ export const FACET_HEADING_HEIGHT = 22;
1029
+
1030
+ /**
1031
+ * The box a group declares for itself — heading, rows, fold — in CSS pixels.
1032
+ *
1033
+ * Stated per SHAPE, because the shapes are not variations of one control: a
1034
+ * dictionary's closed face is one field however many values the vocabulary
1035
+ * holds, a segmented group is a wrapping row of pills, and a checkbox group is
1036
+ * one line per shown option.
1037
+ *
1038
+ * Exported so a host (and this package's own tests) can assert the number the
1039
+ * rail reserves rather than discovering it from a screenshot.
1040
+ */
1041
+ export function facetGroupReservedHeight(input: {
1042
+ readonly shape: FacetGroupShape;
1043
+ /** How many option rows are actually drawn — after the fold, not before. */
1044
+ readonly rows: number;
1045
+ /** Is the group's heading drawn at all? */
1046
+ readonly heading: boolean;
1047
+ /** Is the group open? A closed disclosure is its header and nothing else. */
1048
+ readonly open: boolean;
1049
+ /** Is the "Show all (N)" button drawn under the rows? */
1050
+ readonly folded: boolean;
1051
+ }): number {
1052
+ const gap = input.shape === "segmented" ? spacing[2] : spacing[1];
1053
+ const parts: number[] = [];
1054
+ if (input.heading) parts.push(FACET_HEADING_HEIGHT);
1055
+ if (input.open) {
1056
+ if (input.shape === "dictionary") {
1057
+ parts.push(controls.height);
1058
+ } else if (input.shape === "segmented") {
1059
+ parts.push(
1060
+ Math.ceil(Math.max(input.rows, 1) / FACET_PILLS_PER_ROW) *
1061
+ FACET_PILL_ROW_HEIGHT
1062
+ );
1063
+ } else {
1064
+ for (let row = 0; row < input.rows; row += 1) {
1065
+ parts.push(FACET_OPTION_ROW_HEIGHT);
1066
+ }
1067
+ }
1068
+ if (input.folded) parts.push(FACET_HEADING_HEIGHT);
1069
+ }
1070
+ if (parts.length === 0) return 0;
1071
+ const gaps = (parts.length - 1) * gap;
1072
+ return parts.reduce((total, part) => total + part, 0) + gaps;
985
1073
  }
986
1074
 
987
1075
  /**
@@ -1014,6 +1102,46 @@ export function FacetGroupControl(
1014
1102
  const shape = facetGroupShape(group);
1015
1103
  const nodes = facetOptionNodes(group);
1016
1104
 
1105
+ /*
1106
+ * THE GROUP'S OWN FLOOR WHILE AN ANSWER IS IN FLIGHT (p43).
1107
+ *
1108
+ * The rail keeps the previous answer's groups on screen while the next one
1109
+ * loads — `FacetPanelBag.refreshing` — and the shift it was measured at is
1110
+ * what happens INSIDE that window: a group whose option count changes resizes
1111
+ * and takes every group under it with it.
1112
+ *
1113
+ * So each group remembers the height it was last SETTLED at and stands on it
1114
+ * until the answer lands. Its own last height and never a guess: the whole
1115
+ * point is that this box was that tall a moment ago, on this deployment, at
1116
+ * this width, with this category's options in it.
1117
+ *
1118
+ * Measured in an effect and held in a ref: nothing is written during a
1119
+ * render, and the measurement is deliberately not taken while refreshing —
1120
+ * a reserved box would otherwise remember its own reservation and the floor
1121
+ * could only ever ratchet upwards.
1122
+ *
1123
+ * ── AND THE FIRST MOUNT, WHICH HAD NOTHING (p41) ─────────────────────────
1124
+ *
1125
+ * A measurement is only available from the second answer onwards, so the
1126
+ * paragraph above covers every pass but the one a cold load is made of. Until
1127
+ * a box has been measured the group stands on the box it DECLARES — see
1128
+ * {@link facetGroupReservedHeight} — which is a stated number per shape and
1129
+ * row count rather than whatever the content happened to lay out to. A floor
1130
+ * under the real height is invisible; what it buys is that the box exists at
1131
+ * all in the frames where a label, a count or a dependent axis's options have
1132
+ * not landed yet.
1133
+ */
1134
+ const boxRef = useRef<HTMLDivElement | null>(null);
1135
+ const settledHeight = useRef<number | undefined>(undefined);
1136
+ const refreshing = props.refreshing === true;
1137
+ useEffect(() => {
1138
+ if (refreshing) return;
1139
+ const node = boxRef.current;
1140
+ // `offsetHeight` is 0 in a layout-free environment (jsdom, a server
1141
+ // render): a floor of zero is not a reservation, so nothing is remembered.
1142
+ if (node !== null && node.offsetHeight > 0) settledHeight.current = node.offsetHeight;
1143
+ });
1144
+
1017
1145
  const disclosure = props.collapsible === true && props.heading !== false;
1018
1146
  const open = !disclosure || openState;
1019
1147
 
@@ -1043,13 +1171,54 @@ export function FacetGroupControl(
1043
1171
  ? nodes.slice(0, Math.min(limit ?? nodes.length, nodes.length - demoted))
1044
1172
  : nodes;
1045
1173
 
1174
+ /* The floor, and where it comes from. The DECLARED box is the rows this
1175
+ group is drawing, so it stands on every render and can never be a hole;
1176
+ the MEASURED one — this box, on this deployment, at this width — is added
1177
+ on top of it while an answer is in flight, and released with the answer.
1178
+ Zero is not a reservation and is never written. */
1179
+ const declared = facetGroupReservedHeight({
1180
+ shape,
1181
+ rows: shown.length,
1182
+ heading: props.heading !== false,
1183
+ open,
1184
+ folded,
1185
+ });
1186
+ const measured = settledHeight.current;
1187
+ /* The declared box stands ALWAYS — it is the rows this group is drawing, so
1188
+ it can never be a hole — and the measured one is added only while an
1189
+ answer is in flight. That is the difference between the two: the
1190
+ declaration is what this group needs, and the measurement is what it
1191
+ happened to occupy a moment ago, which is a floor worth holding exactly as
1192
+ long as the thing that filled it is being replaced. */
1193
+ const reserved =
1194
+ (refreshing ? Math.max(measured ?? 0, declared) : declared) || undefined;
1195
+ const reservedSource =
1196
+ reserved === undefined
1197
+ ? undefined
1198
+ : refreshing && measured !== undefined && measured > declared
1199
+ ? "measured"
1200
+ : "declared";
1201
+
1046
1202
  return (
1047
1203
  <Flex
1204
+ ref={boxRef}
1048
1205
  vertical
1049
1206
  gap={shape === "segmented" ? spacing[2] : spacing[1]}
1207
+ // A FLOOR, not a height: `min-block-size` lets a group that needs more
1208
+ // room take it, and holds the box it had for one that would shrink.
1209
+ {...(reserved !== undefined ? { style: { minBlockSize: reserved } } : {})}
1050
1210
  data-testid={`facet-group-${group.slug}`}
1051
1211
  data-counted={group.counted ? "true" : "false"}
1052
1212
  data-shape={shape}
1213
+ // The reservation, readable from a stand: `data-reserved` is the height
1214
+ // this group is standing on, and `data-reserved-source` says whether the
1215
+ // number was MEASURED on this deployment or DECLARED by the pair — which
1216
+ // is the difference between a refresh and a first mount.
1217
+ {...(refreshing ? { "data-refreshing": "true" } : {})}
1218
+ {...(reserved !== undefined ? { "data-reserved": String(reserved) } : {})}
1219
+ {...(reservedSource !== undefined
1220
+ ? { "data-reserved-source": reservedSource }
1221
+ : {})}
1053
1222
  // Who named this heading — `none` means the raw slug is on screen
1054
1223
  // because the answer sent no label and the schema defines none. It is
1055
1224
  // drawn (a heading a person cannot read still beats none) and it is
@@ -84,6 +84,7 @@ import {
84
84
  SlotPlaceholder,
85
85
  actionAvailable,
86
86
  actionBlocked,
87
+ loadLoading,
87
88
  useT,
88
89
  useTPlural,
89
90
  } from "@stapel/core";
@@ -230,6 +231,31 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
230
231
  * slugs get a numeric range row, and of which slugs are a filter at all
231
232
  * (`isFacetableFeature`: an `imei` is counted and is not one). */
232
233
  readonly categoryFeatures?: readonly FeatureDef[];
234
+ /**
235
+ * The schema is ON ITS WAY — the third state {@link categoryFeatures} does
236
+ * not have, and the whole of p41.
237
+ *
238
+ * `categoryFeatures: undefined` says two different things today: "this
239
+ * category hangs no schema" and "the read has not answered yet". The panel
240
+ * cannot tell them apart, so on a page whose schema is a SEPARATE read from
241
+ * the search (a category leaf: two requests, two arrival times) it draws the
242
+ * rail the moment the answer lands and then draws it AGAIN, differently, when
243
+ * the schema arrives — because both the SHAPE of every group
244
+ * (`facetGroupShape` reads `maxSelected` and `ref_select` off the feature)
245
+ * and the ORDER of the rail (`orderFacetGroupsBySchema` puts the schema's
246
+ * required axes first) are functions of it. Measured on a live cars leaf: the
247
+ * make and the model went from three-row checkbox lists to one-row dictionary
248
+ * fields, condition and colour from checkboxes to pills, and the order
249
+ * changed under all of them — 0.0586 CLS on a plain cold load, with
250
+ * `facet-group-make` moving 152px and `facet-group-model` 76px (p41).
251
+ *
252
+ * With `categoryFeaturesPending` the panel keeps the box it already reserves
253
+ * for a load in flight until the schema has spoken, and draws the rail ONCE,
254
+ * in the shape and the order it will keep. A host passes its schema query's
255
+ * own pending flag; the default is `false`, so a surface that never had a
256
+ * schema to wait for is unchanged.
257
+ */
258
+ readonly categoryFeaturesPending?: boolean;
233
259
  readonly locale?: string;
234
260
  readonly enabled?: boolean;
235
261
  /**
@@ -620,6 +646,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
620
646
  : {})}
621
647
  >
622
648
  {(bag) => {
649
+ /* THE ANSWER THIS PANEL DRAWS FROM, AND WHEN IT IS ALLOWED TO (p41).
650
+ A schema still in flight makes the groups in hand provisional: they
651
+ would be drawn in one shape and one order now and in another the
652
+ moment it lands. `loadLoading()` is the honest state for that — the
653
+ panel already reserves a box for it — and it is the SAME state the
654
+ pane hands every consumer below, so the ranges arm, the empty arm
655
+ and the group list cannot disagree about whether there is an answer
656
+ on screen. See `FacetPanelPaneProps.categoryFeaturesPending`. */
657
+ const schemaPending = props.categoryFeaturesPending === true;
658
+ const answer = schemaPending ? loadLoading() : bag.state;
623
659
  // Built INSIDE the bag, because which axes exist is a property of
624
660
  // the ANSWER now: `facet_meta.core_ranges` names the core columns
625
661
  // this server can actually filter on (`r.price`), and the corpus
@@ -735,7 +771,26 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
735
771
  </GatedButton>
736
772
  );
737
773
  return (
738
- <Flex vertical gap={spacing[3]} data-testid="search-facets">
774
+ <Flex
775
+ vertical
776
+ gap={spacing[3]}
777
+ data-testid="search-facets"
778
+ /* THE RAIL SAYS WHEN ITS ANSWER IS IN FLIGHT (p43).
779
+ A partition press measured 0.0586 CLS here: the rail survives —
780
+ the groups are the previous answer's and stay mounted — and the
781
+ groups RESIZE as the new axis's facets land. Each group holds
782
+ its own floor while this is on (see `FacetGroupControl
783
+ refreshing`), and the attribute is the host's half: a storefront
784
+ that dims or freezes the column has one thing to hang it on
785
+ instead of racing the pair's own queries to find out. */
786
+ data-facets-refreshing={bag.refreshing ? "true" : "false"}
787
+ /* AND WHEN ITS SHAPE IS NOT DECIDED YET (p41). The groups in hand
788
+ are provisional while the category's schema is in flight, because
789
+ the schema decides both the shape of every group and the order of
790
+ the rail. A walker reads this to know why the column is a
791
+ skeleton with a settled answer behind it. */
792
+ data-facets-schema={schemaPending ? "pending" : "settled"}
793
+ >
739
794
  {/* In a 280px rail this row laid the word "Filters" out in a
740
795
  43x78 box, three lines, one syllable each — see FACET_HEADING.
741
796
  `wrap` is the row's half of the fix: the long sentence drops to
@@ -823,8 +878,8 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
823
878
  the checkboxes on a leaf whose only filter is a price — the one
824
879
  shape where the rail is nothing else. Drawn here, in the place
825
880
  the merged list would have put them. */}
826
- {bag.state.status === "ready" &&
827
- bag.state.data.length === 0 &&
881
+ {answer.status === "ready" &&
882
+ answer.data.length === 0 &&
828
883
  ranges.length > 0 && (
829
884
  <Flex vertical gap={spacing[3]} data-testid="search-ranges">
830
885
  {ranges.map(rangeRow)}
@@ -851,7 +906,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
851
906
  many skeleton rows, each `RANGE_ROW_MIN_HEIGHT` tall like
852
907
  the row it will become. */}
853
908
  <LoadList
854
- state={bag.state}
909
+ state={answer}
855
910
  testId="facets"
856
911
  skeletonRows={4}
857
912
  loading={
@@ -1042,6 +1097,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
1042
1097
  group={item.group}
1043
1098
  onToggle={bag.toggle}
1044
1099
  onSetValues={bag.setValues}
1100
+ // Its own last height, held until the answer lands,
1101
+ // so a group that changes option count does not move
1102
+ // the groups under it.
1103
+ refreshing={bag.refreshing}
1045
1104
  collapsible
1046
1105
  defaultOpen={
1047
1106
  needle !== "" ||
@@ -312,6 +312,14 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
312
312
  readonly adapter: SearchParamsAdapter;
313
313
  readonly renderCard?: SearchCardRenderer;
314
314
  readonly categoryFeatures?: readonly FeatureDef[];
315
+ /**
316
+ * The schema is ON ITS WAY — the third state `categoryFeatures` does not
317
+ * have. Handed straight to the filter panel, which holds the box it already
318
+ * reserves rather than drawing a rail it is about to re-shape and re-order.
319
+ * See {@link FacetPanelPaneProps.categoryFeaturesPending} for what was
320
+ * measured without it (p41).
321
+ */
322
+ readonly categoryFeaturesPending?: boolean;
315
323
  readonly locale?: string;
316
324
  /**
317
325
  * Name the facet values neither the answer nor the category schema names —
@@ -431,6 +439,22 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
431
439
  * sense that matters and not a decoration bolted on top.
432
440
  */
433
441
  readonly filtersHeader?: ReactNode;
442
+ /**
443
+ * The block-size that slot will END UP at, declared before it has anything
444
+ * in it — a number in CSS pixels or any length (`"96px"`, `"6rem"`).
445
+ *
446
+ * For the host whose header is a SEPARATE read from the search: a category
447
+ * page's partition row is two chained catalogue requests behind the answer,
448
+ * so the rail draws its groups first and the row drops in over them a beat
449
+ * later, pushing every filter under it down (p41). The band is in flow from
450
+ * the first frame with this set — an empty box of the right height, then the
451
+ * control inside it — and the pair never guesses the number, because the
452
+ * height of a control it does not own is not the pair's to know.
453
+ *
454
+ * A FLOOR, like every other reservation here: a header taller than the
455
+ * declared number still takes the room it needs.
456
+ */
457
+ readonly filtersHeaderReserve?: number | string;
434
458
  /**
435
459
  * The row ABOVE the chips and the results — where `<LocationSummaryLine>`
436
460
  * goes on the phone SERP.
@@ -679,6 +703,8 @@ interface SearchPageBodyProps {
679
703
  readonly dictionaryMode?: "field" | "inline" | "sheet";
680
704
  readonly visibleGroups?: number | null;
681
705
  readonly categoryFeatures?: readonly FeatureDef[];
706
+ readonly categoryFeaturesPending?: boolean;
707
+ readonly filtersHeaderReserve?: number | string;
682
708
  readonly renderEmptyExits?: () => ReactNode;
683
709
  readonly locale?: string;
684
710
  readonly pinnedFacets?: readonly string[];
@@ -787,7 +813,13 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
787
813
  // have called that column empty.
788
814
  ...(facets.ranges !== undefined ? { ranges: facets.ranges } : {}),
789
815
  });
816
+ /* A schema still in flight makes every reading below provisional — see
817
+ `FacetPanelPaneProps.categoryFeaturesPending`. "Nothing to filter by" in
818
+ particular: the groups in hand are the ones the panel is about to redraw,
819
+ and a column closed on them would open again a beat later. */
820
+ const schemaPending = props.categoryFeaturesPending === true;
790
821
  const filtersEmpty =
822
+ !schemaPending &&
791
823
  facets.state.status === "ready" &&
792
824
  facets.state.data.length === 0 &&
793
825
  // `withheld` (groups the server counted and held back for covering too
@@ -806,11 +838,28 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
806
838
  props.renderCategoryFilter === undefined)) &&
807
839
  state.lang === undefined &&
808
840
  (props.languages ?? []).length === 0;
809
- const showFilters = filtersHeader !== undefined || !filtersEmpty;
841
+ const showFilters =
842
+ filtersHeader !== undefined ||
843
+ props.filtersHeaderReserve !== undefined ||
844
+ !filtersEmpty;
810
845
 
811
846
  const panel = (
812
847
  <Flex vertical gap={spacing[4]}>
813
- {filtersHeader}
848
+ {/* THE HOST'S BAND, IN FLOW FROM THE FIRST FRAME (p41). The wrapper is
849
+ drawn whenever there is something to draw OR a height to hold, so a
850
+ header that arrives with a second read lands INTO its box rather than
851
+ inserting one above the groups. See `filtersHeaderReserve`. */}
852
+ {(filtersHeader !== undefined ||
853
+ props.filtersHeaderReserve !== undefined) && (
854
+ <div
855
+ data-testid="search-filters-header"
856
+ {...(props.filtersHeaderReserve !== undefined
857
+ ? { style: { minBlockSize: props.filtersHeaderReserve } }
858
+ : {})}
859
+ >
860
+ {filtersHeader}
861
+ </div>
862
+ )}
814
863
  {/* The facet panel is skipped entirely when the only thing it would draw
815
864
  is its own empty state and the column is open for the host's control
816
865
  alone — one empty-state illustration under a working filter is still
@@ -841,6 +890,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
841
890
  : 16
842
891
  }
843
892
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
893
+ {...(props.categoryFeaturesPending !== undefined
894
+ ? { categoryFeaturesPending: props.categoryFeaturesPending }
895
+ : {})}
844
896
  {...(props.renderEmptyExits !== undefined
845
897
  ? { renderEmptyExits: props.renderEmptyExits }
846
898
  : {})}
@@ -1094,6 +1146,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1094
1146
  adapter,
1095
1147
  renderCard,
1096
1148
  categoryFeatures,
1149
+ categoryFeaturesPending,
1097
1150
  locale,
1098
1151
  resolveFacetLabels,
1099
1152
  searchBox,
@@ -1106,6 +1159,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1106
1159
  geoOffer,
1107
1160
  footer,
1108
1161
  filtersHeader,
1162
+ filtersHeaderReserve,
1109
1163
  resultsHeader,
1110
1164
  resultsLead,
1111
1165
  categoryFilter,
@@ -1149,6 +1203,9 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1149
1203
  {...(visibleGroups !== undefined ? { visibleGroups } : {})}
1150
1204
  {...(pinnedFacets !== undefined ? { pinnedFacets } : {})}
1151
1205
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
1206
+ {...(categoryFeaturesPending !== undefined
1207
+ ? { categoryFeaturesPending }
1208
+ : {})}
1152
1209
  {...(locale !== undefined ? { locale } : {})}
1153
1210
  {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
1154
1211
  {...(searchBox !== undefined ? { searchBox } : {})}
@@ -1160,6 +1217,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1160
1217
  {...(skippedNotice !== undefined ? { skippedNotice } : {})}
1161
1218
  {...(footer !== undefined ? { footer } : {})}
1162
1219
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
1220
+ {...(filtersHeaderReserve !== undefined ? { filtersHeaderReserve } : {})}
1163
1221
  {...(resultsHeader !== undefined ? { resultsHeader } : {})}
1164
1222
  {...(resultsLead !== undefined ? { resultsLead } : {})}
1165
1223
  {...(categoryFilter !== undefined ? { categoryFilter } : {})}
@@ -122,10 +122,15 @@ export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
122
122
  export {
123
123
  FacetGroupControl,
124
124
  facetGroupIsEmptyHeading,
125
+ facetGroupReservedHeight,
125
126
  facetGroupShape,
126
127
  facetOptionNodes,
127
128
  isDictionaryFacet,
128
129
  FACET_DICTIONARY_THRESHOLD,
130
+ FACET_HEADING_HEIGHT,
131
+ FACET_OPTION_ROW_HEIGHT,
132
+ FACET_PILLS_PER_ROW,
133
+ FACET_PILL_ROW_HEIGHT,
129
134
  FACET_SHEET_PAGE,
130
135
  FACET_VISIBLE_OPTIONS,
131
136
  } from "./FacetGroupControl.js";
@@ -29,6 +29,17 @@ export interface FacetPanelBag {
29
29
  * category) — it is NOT what a failed query looks like.
30
30
  */
31
31
  readonly state: LoadState<readonly FacetGroup[]>;
32
+ /**
33
+ * The groups in hand belong to the PREVIOUS answer, and a newer one is in
34
+ * flight — `LoadReady.refreshing`, lifted out of the state so a skin can
35
+ * read it without narrowing the union first.
36
+ *
37
+ * A skin holds its geometry still while this is `true`: the axes are about
38
+ * to change, and a rail that resizes group by group as they arrive is the
39
+ * 0.0586 CLS a partition press measured (p43). It is never `true` on a
40
+ * first load — there is nothing to hold still then.
41
+ */
42
+ readonly refreshing: boolean;
32
43
  /**
33
44
  * `true` when the counts came from a SAMPLE because the candidate set
34
45
  * exceeded the backend's cap. The panel must say so — the spec makes this
@@ -228,7 +239,23 @@ export function useFacetPanel(props: {
228
239
  props.enabled !== undefined ? { enabled: props.enabled } : undefined
229
240
  );
230
241
 
231
- const envelope = loadStateFromQuery(query);
242
+ /*
243
+ * KEEP THE PREVIOUS ANSWER, AND SAY SO (p43, CLS 0.0586 on a partition press).
244
+ *
245
+ * `useSearchQuery` already runs with `placeholderData: keepPreviousData`, so
246
+ * the data in hand during a key change is the previous answer's — the panel
247
+ * has been drawing it for three releases. What it could not do is TELL a skin
248
+ * that this is what it was doing, so the rail resized group by group as the
249
+ * new axis's facets landed: `facet-group-make` and `facet-group-model` moved
250
+ * their neighbours as their option counts changed under them.
251
+ *
252
+ * `keepPrevious` reads TanStack's own `isPlaceholderData` into
253
+ * `LoadReady.refreshing`, which every projection below carries across
254
+ * (`mapLoad`, and `useHostFacetLabels` through it). It is set on EVERY ready
255
+ * answer once asked for, `false` included, so a skin keying its DOM off it
256
+ * does not grow and drop a wrapper as the flag comes and goes.
257
+ */
258
+ const envelope = loadStateFromQuery(query, { keepPrevious: true });
232
259
  const meta = envelope.status === "ready" ? envelope.data.facet_meta : EMPTY_META;
233
260
 
234
261
  const groups = mapLoad(envelope, (data) =>
@@ -271,6 +298,9 @@ export function useFacetPanel(props: {
271
298
 
272
299
  return {
273
300
  state: labelled,
301
+ // Read off the ANSWER's own state rather than off the projection, so a
302
+ // label pass that returns the groups untouched cannot lose it.
303
+ refreshing: envelope.status === "ready" && envelope.refreshing === true,
274
304
  approximate: meta.approximate,
275
305
  skipped: meta.skipped,
276
306
  counted: meta.counted,