@stapel/search-react 0.34.0 → 0.36.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.
@@ -997,6 +997,81 @@ export interface FacetGroupControlProps {
997
997
  readonly refreshing?: boolean;
998
998
  }
999
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;
1073
+ }
1074
+
1000
1075
  /**
1001
1076
  * Is this group a HEADING WITH NOTHING UNDER IT?
1002
1077
  *
@@ -1044,6 +1119,17 @@ export function FacetGroupControl(
1044
1119
  * render, and the measurement is deliberately not taken while refreshing —
1045
1120
  * a reserved box would otherwise remember its own reservation and the floor
1046
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.
1047
1133
  */
1048
1134
  const boxRef = useRef<HTMLDivElement | null>(null);
1049
1135
  const settledHeight = useRef<number | undefined>(undefined);
@@ -1055,7 +1141,6 @@ export function FacetGroupControl(
1055
1141
  // render): a floor of zero is not a reservation, so nothing is remembered.
1056
1142
  if (node !== null && node.offsetHeight > 0) settledHeight.current = node.offsetHeight;
1057
1143
  });
1058
- const reserved = refreshing ? settledHeight.current : undefined;
1059
1144
 
1060
1145
  const disclosure = props.collapsible === true && props.heading !== false;
1061
1146
  const open = !disclosure || openState;
@@ -1086,6 +1171,34 @@ export function FacetGroupControl(
1086
1171
  ? nodes.slice(0, Math.min(limit ?? nodes.length, nodes.length - demoted))
1087
1172
  : nodes;
1088
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
+
1089
1202
  return (
1090
1203
  <Flex
1091
1204
  ref={boxRef}
@@ -1098,9 +1211,14 @@ export function FacetGroupControl(
1098
1211
  data-counted={group.counted ? "true" : "false"}
1099
1212
  data-shape={shape}
1100
1213
  // The reservation, readable from a stand: `data-reserved` is the height
1101
- // this group is standing on while the next answer is in flight.
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.
1102
1217
  {...(refreshing ? { "data-refreshing": "true" } : {})}
1103
1218
  {...(reserved !== undefined ? { "data-reserved": String(reserved) } : {})}
1219
+ {...(reservedSource !== undefined
1220
+ ? { "data-reserved-source": reservedSource }
1221
+ : {})}
1104
1222
  // Who named this heading — `none` means the raw slug is on screen
1105
1223
  // because the answer sent no label and the schema defines none. It is
1106
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
@@ -748,6 +784,12 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
748
784
  that dims or freezes the column has one thing to hang it on
749
785
  instead of racing the pair's own queries to find out. */
750
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"}
751
793
  >
752
794
  {/* In a 280px rail this row laid the word "Filters" out in a
753
795
  43x78 box, three lines, one syllable each — see FACET_HEADING.
@@ -836,8 +878,8 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
836
878
  the checkboxes on a leaf whose only filter is a price — the one
837
879
  shape where the rail is nothing else. Drawn here, in the place
838
880
  the merged list would have put them. */}
839
- {bag.state.status === "ready" &&
840
- bag.state.data.length === 0 &&
881
+ {answer.status === "ready" &&
882
+ answer.data.length === 0 &&
841
883
  ranges.length > 0 && (
842
884
  <Flex vertical gap={spacing[3]} data-testid="search-ranges">
843
885
  {ranges.map(rangeRow)}
@@ -864,7 +906,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
864
906
  many skeleton rows, each `RANGE_ROW_MIN_HEIGHT` tall like
865
907
  the row it will become. */}
866
908
  <LoadList
867
- state={bag.state}
909
+ state={answer}
868
910
  testId="facets"
869
911
  skeletonRows={4}
870
912
  loading={
@@ -107,6 +107,54 @@ import type { ThemeModeProp } from "./types.js";
107
107
  /** Where the filters live: beside the results, or behind a button in a sheet. */
108
108
  export type SearchFiltersLayout = "column" | "sheet";
109
109
 
110
+ /**
111
+ * WHY the filter sheet's open state moved — handed to `onFiltersOpenChange`
112
+ * beside the new value.
113
+ *
114
+ * A host that only mirrors the boolean can ignore it. A host that ACTS on the
115
+ * close cannot: "the person pressed Show results" and "the person swiped the
116
+ * sheet away" are the same `false` and not the same event, and a container
117
+ * that logs one as the other reports an intent nobody had.
118
+ *
119
+ * - `open` — the sheet was asked for (the all-filters chip, the location
120
+ * row's door).
121
+ * - `apply` — the footer's "Show N results" committed and closed it.
122
+ * - `dismiss` — the dialog itself closed: the X, the scrim, Escape.
123
+ * - `consumer` — the host closed it through `filtersHeader`'s `closeFilters`.
124
+ */
125
+ export type SearchFiltersOpenReason = "open" | "apply" | "dismiss" | "consumer";
126
+
127
+ /** What `filtersHeader` is handed when a host passes a function. */
128
+ export interface SearchFiltersHeaderSlotProps {
129
+ /**
130
+ * Shut the sheet, with the reason `"consumer"`.
131
+ *
132
+ * This is the whole point of the callback form: a control in this slot that
133
+ * NAVIGATES on a press (a partition or axis chip that changes the route)
134
+ * has to close the sheet on that same press, or the next page opens under a
135
+ * drawer that is still up. Without it a host had to lift the open state out
136
+ * of the page — which it could not, because the page only offered
137
+ * `defaultFiltersOpen`.
138
+ *
139
+ * Harmless in the column layout, where there is no sheet to shut: the page
140
+ * still reports the change, and nothing moves on screen.
141
+ */
142
+ readonly closeFilters: () => void;
143
+ /** Is the sheet open around this header right now. */
144
+ readonly open: boolean;
145
+ }
146
+
147
+ /**
148
+ * The `filtersHeader` slot: a node, or a function told the sheet's state.
149
+ *
150
+ * The node form is what it has always been. The function form exists so a
151
+ * header can close the sheet it is inside without the host owning the open
152
+ * state — see {@link SearchFiltersHeaderSlotProps.closeFilters}.
153
+ */
154
+ export type SearchFiltersHeader =
155
+ | ReactNode
156
+ | ((slot: SearchFiltersHeaderSlotProps) => ReactNode);
157
+
110
158
  /**
111
159
  * WHERE the filter rail earns its 280px, when the token `tablet` edge is the
112
160
  * wrong place to draw it.
@@ -312,6 +360,14 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
312
360
  readonly adapter: SearchParamsAdapter;
313
361
  readonly renderCard?: SearchCardRenderer;
314
362
  readonly categoryFeatures?: readonly FeatureDef[];
363
+ /**
364
+ * The schema is ON ITS WAY — the third state `categoryFeatures` does not
365
+ * have. Handed straight to the filter panel, which holds the box it already
366
+ * reserves rather than drawing a rail it is about to re-shape and re-order.
367
+ * See {@link FacetPanelPaneProps.categoryFeaturesPending} for what was
368
+ * measured without it (p41).
369
+ */
370
+ readonly categoryFeaturesPending?: boolean;
315
371
  readonly locale?: string;
316
372
  /**
317
373
  * Name the facet values neither the answer nor the category schema names —
@@ -429,8 +485,28 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
429
485
  * for. Whatever a host renders here reads and writes the same URL state as
430
486
  * the facets beside it (`useSearchState()`), so it is a filter in every
431
487
  * sense that matters and not a decoration bolted on top.
488
+ *
489
+ * A FUNCTION is handed `{ closeFilters, open }` — for a header whose own
490
+ * control navigates away, which on a phone has to take the sheet down with
491
+ * it. See {@link SearchFiltersHeaderSlotProps}.
432
492
  */
433
- readonly filtersHeader?: ReactNode;
493
+ readonly filtersHeader?: SearchFiltersHeader;
494
+ /**
495
+ * The block-size that slot will END UP at, declared before it has anything
496
+ * in it — a number in CSS pixels or any length (`"96px"`, `"6rem"`).
497
+ *
498
+ * For the host whose header is a SEPARATE read from the search: a category
499
+ * page's partition row is two chained catalogue requests behind the answer,
500
+ * so the rail draws its groups first and the row drops in over them a beat
501
+ * later, pushing every filter under it down (p41). The band is in flow from
502
+ * the first frame with this set — an empty box of the right height, then the
503
+ * control inside it — and the pair never guesses the number, because the
504
+ * height of a control it does not own is not the pair's to know.
505
+ *
506
+ * A FLOOR, like every other reservation here: a header taller than the
507
+ * declared number still takes the room it needs.
508
+ */
509
+ readonly filtersHeaderReserve?: number | string;
434
510
  /**
435
511
  * The row ABOVE the chips and the results — where `<LocationSummaryLine>`
436
512
  * goes on the phone SERP.
@@ -600,9 +676,47 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
600
676
  * from a category page), and for the story that photographs the sheet —
601
677
  * a state reached only by a tap is a state nothing outside a browser has
602
678
  * ever seen. The person still closes it; this is the initial value, not a
603
- * controlled one.
679
+ * controlled one — pass `filtersOpen` for that.
604
680
  */
605
681
  readonly defaultFiltersOpen?: boolean;
682
+ /**
683
+ * The sheet's open state, OWNED BY THE HOST.
684
+ *
685
+ * React's usual contract: pass this and the page stops keeping its own copy
686
+ * — every open and every close is a call to `onFiltersOpenChange` and
687
+ * nothing moves until the value comes back. Leave it out and the page is
688
+ * uncontrolled exactly as before, `defaultFiltersOpen` its initial value.
689
+ *
690
+ * It exists because a control the host renders INSIDE the sheet can end the
691
+ * search that sheet belongs to. A partition chip in `filtersHeader` that
692
+ * navigates leaves the drawer standing over the page it opened, and the
693
+ * host had no handle on the state to shut it — the page exposed the initial
694
+ * value and nothing else. A host that only needs the close and not the
695
+ * state can take `filtersHeader`'s `closeFilters` instead and keep the page
696
+ * uncontrolled.
697
+ *
698
+ * ```tsx
699
+ * const [filtersOpen, setFiltersOpen] = useState(false);
700
+ * <SearchPage
701
+ * filtersOpen={filtersOpen}
702
+ * onFiltersOpenChange={(open) => { setFiltersOpen(open); }}
703
+ * filtersHeader={<PartitionChips onPick={(href) => { setFiltersOpen(false); navigate(href); }} />}
704
+ * />
705
+ * ```
706
+ */
707
+ readonly filtersOpen?: boolean;
708
+ /**
709
+ * Told that the sheet wants to open or close, and WHY — see
710
+ * {@link SearchFiltersOpenReason}.
711
+ *
712
+ * Called in both modes, controlled and not: a host that wants to watch the
713
+ * sheet (an analytics event on the dismiss, a scroll lock of its own) does
714
+ * not have to take ownership of the state to hear about it.
715
+ */
716
+ readonly onFiltersOpenChange?: (
717
+ open: boolean,
718
+ reason: SearchFiltersOpenReason
719
+ ) => void;
606
720
  /** Offer a page-size control beside the sort. Default `true`. */
607
721
  readonly pageSize?: boolean;
608
722
  /**
@@ -679,6 +793,8 @@ interface SearchPageBodyProps {
679
793
  readonly dictionaryMode?: "field" | "inline" | "sheet";
680
794
  readonly visibleGroups?: number | null;
681
795
  readonly categoryFeatures?: readonly FeatureDef[];
796
+ readonly categoryFeaturesPending?: boolean;
797
+ readonly filtersHeaderReserve?: number | string;
682
798
  readonly renderEmptyExits?: () => ReactNode;
683
799
  readonly locale?: string;
684
800
  readonly pinnedFacets?: readonly string[];
@@ -691,7 +807,7 @@ interface SearchPageBodyProps {
691
807
  readonly geoLabel?: ReactNode;
692
808
  readonly skippedNotice?: boolean;
693
809
  readonly footer?: ReactNode;
694
- readonly filtersHeader?: ReactNode;
810
+ readonly filtersHeader?: SearchFiltersHeader;
695
811
  readonly resultsHeader?: ReactNode;
696
812
  readonly appliedChips?: boolean | "desktop";
697
813
  readonly otherCategories?: boolean;
@@ -705,6 +821,11 @@ interface SearchPageBodyProps {
705
821
  readonly railTop?: number | string;
706
822
  readonly stickyToolbar?: SearchToolbarPin;
707
823
  readonly defaultFiltersOpen?: boolean;
824
+ readonly filtersOpen?: boolean;
825
+ readonly onFiltersOpenChange?: (
826
+ open: boolean,
827
+ reason: SearchFiltersOpenReason
828
+ ) => void;
708
829
  readonly pageSize?: boolean;
709
830
  readonly breadcrumb?: ReactNode;
710
831
  readonly wrapResults?: SearchResultsWrapper;
@@ -741,7 +862,26 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
741
862
  : surface === "sheet"
742
863
  ? "sheet"
743
864
  : "column");
744
- const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
865
+ // Controlled or not, decided by the PRESENCE of `filtersOpen` and read once
866
+ // per render — the state the page keeps is only ever the uncontrolled half,
867
+ // and a controlled host's value is never copied into it (copying it is how
868
+ // a controlled component starts disagreeing with its owner one frame after
869
+ // the owner refuses a change).
870
+ const controlledFiltersOpen = props.filtersOpen;
871
+ const [ownFiltersOpen, setOwnFiltersOpen] = useState(
872
+ props.defaultFiltersOpen === true
873
+ );
874
+ const sheetOpen = controlledFiltersOpen ?? ownFiltersOpen;
875
+ const { onFiltersOpenChange } = props;
876
+ const setSheetOpen = (next: boolean, reason: SearchFiltersOpenReason): void => {
877
+ if (controlledFiltersOpen === undefined) {
878
+ setOwnFiltersOpen(next);
879
+ }
880
+ onFiltersOpenChange?.(next, reason);
881
+ };
882
+ const closeFilters = (): void => {
883
+ setSheetOpen(false, "consumer");
884
+ };
745
885
 
746
886
  // How the results are ARRANGED. Component state, not URL state: it changes
747
887
  // how the same answer is drawn, never what the answer is, so it must not
@@ -787,7 +927,13 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
787
927
  // have called that column empty.
788
928
  ...(facets.ranges !== undefined ? { ranges: facets.ranges } : {}),
789
929
  });
930
+ /* A schema still in flight makes every reading below provisional — see
931
+ `FacetPanelPaneProps.categoryFeaturesPending`. "Nothing to filter by" in
932
+ particular: the groups in hand are the ones the panel is about to redraw,
933
+ and a column closed on them would open again a beat later. */
934
+ const schemaPending = props.categoryFeaturesPending === true;
790
935
  const filtersEmpty =
936
+ !schemaPending &&
791
937
  facets.state.status === "ready" &&
792
938
  facets.state.data.length === 0 &&
793
939
  // `withheld` (groups the server counted and held back for covering too
@@ -806,11 +952,35 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
806
952
  props.renderCategoryFilter === undefined)) &&
807
953
  state.lang === undefined &&
808
954
  (props.languages ?? []).length === 0;
809
- const showFilters = filtersHeader !== undefined || !filtersEmpty;
955
+ const showFilters =
956
+ filtersHeader !== undefined ||
957
+ props.filtersHeaderReserve !== undefined ||
958
+ !filtersEmpty;
810
959
 
811
960
  const panel = (
812
961
  <Flex vertical gap={spacing[4]}>
813
- {filtersHeader}
962
+ {/* THE HOST'S BAND, IN FLOW FROM THE FIRST FRAME (p41). The wrapper is
963
+ drawn whenever there is something to draw OR a height to hold, so a
964
+ header that arrives with a second read lands INTO its box rather than
965
+ inserting one above the groups. See `filtersHeaderReserve`. */}
966
+ {(filtersHeader !== undefined ||
967
+ props.filtersHeaderReserve !== undefined) && (
968
+ <div
969
+ data-testid="search-filters-header"
970
+ {...(props.filtersHeaderReserve !== undefined
971
+ ? { style: { minBlockSize: props.filtersHeaderReserve } }
972
+ : {})}
973
+ >
974
+ {/* The function form is called HERE, inside the slot it fills, so a
975
+ header that reads `open` re-renders with the sheet. Presence is
976
+ still decided by the PROP above and never by what the function
977
+ returned: a header that renders nothing this frame must not
978
+ collapse the box it reserved. */}
979
+ {typeof filtersHeader === "function"
980
+ ? filtersHeader({ closeFilters, open: sheetOpen })
981
+ : filtersHeader}
982
+ </div>
983
+ )}
814
984
  {/* The facet panel is skipped entirely when the only thing it would draw
815
985
  is its own empty state and the column is open for the host's control
816
986
  alone — one empty-state illustration under a working filter is still
@@ -841,6 +1011,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
841
1011
  : 16
842
1012
  }
843
1013
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
1014
+ {...(props.categoryFeaturesPending !== undefined
1015
+ ? { categoryFeaturesPending: props.categoryFeaturesPending }
1016
+ : {})}
844
1017
  {...(props.renderEmptyExits !== undefined
845
1018
  ? { renderEmptyExits: props.renderEmptyExits }
846
1019
  : {})}
@@ -984,7 +1157,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
984
1157
  // has the whole panel on screen beside this row.
985
1158
  filtersDoor={layout === "sheet"}
986
1159
  onOpenAll={() => {
987
- setSheetOpen(true);
1160
+ setSheetOpen(true, "open");
988
1161
  }}
989
1162
  />
990
1163
  )}
@@ -1021,7 +1194,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1021
1194
  still the whole panel, for the person who wants all of it. */}
1022
1195
  <FilterChips
1023
1196
  onOpenAll={() => {
1024
- setSheetOpen(true);
1197
+ setSheetOpen(true, "open");
1025
1198
  }}
1026
1199
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
1027
1200
  {...(locale !== undefined ? { locale } : {})}
@@ -1040,7 +1213,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1040
1213
  <SkinDialog
1041
1214
  open={sheetOpen}
1042
1215
  onClose={() => {
1043
- setSheetOpen(false);
1216
+ setSheetOpen(false, "dismiss");
1044
1217
  }}
1045
1218
  title={t(SEARCH_I18N_KEYS.facetsTitle)}
1046
1219
  dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
@@ -1053,7 +1226,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1053
1226
  data-analytics="none"
1054
1227
  data-analytics-reason="the filters are already applied; this closes the sheet"
1055
1228
  onClick={() => {
1056
- setSheetOpen(false);
1229
+ setSheetOpen(false, "apply");
1057
1230
  }}
1058
1231
  >
1059
1232
  {applyLabel}
@@ -1094,6 +1267,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1094
1267
  adapter,
1095
1268
  renderCard,
1096
1269
  categoryFeatures,
1270
+ categoryFeaturesPending,
1097
1271
  locale,
1098
1272
  resolveFacetLabels,
1099
1273
  searchBox,
@@ -1106,6 +1280,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1106
1280
  geoOffer,
1107
1281
  footer,
1108
1282
  filtersHeader,
1283
+ filtersHeaderReserve,
1109
1284
  resultsHeader,
1110
1285
  resultsLead,
1111
1286
  categoryFilter,
@@ -1121,6 +1296,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1121
1296
  railTop,
1122
1297
  stickyToolbar,
1123
1298
  defaultFiltersOpen,
1299
+ filtersOpen,
1300
+ onFiltersOpenChange,
1124
1301
  pageSize,
1125
1302
  breadcrumb,
1126
1303
  wrapResults,
@@ -1149,6 +1326,9 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1149
1326
  {...(visibleGroups !== undefined ? { visibleGroups } : {})}
1150
1327
  {...(pinnedFacets !== undefined ? { pinnedFacets } : {})}
1151
1328
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
1329
+ {...(categoryFeaturesPending !== undefined
1330
+ ? { categoryFeaturesPending }
1331
+ : {})}
1152
1332
  {...(locale !== undefined ? { locale } : {})}
1153
1333
  {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
1154
1334
  {...(searchBox !== undefined ? { searchBox } : {})}
@@ -1160,6 +1340,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1160
1340
  {...(skippedNotice !== undefined ? { skippedNotice } : {})}
1161
1341
  {...(footer !== undefined ? { footer } : {})}
1162
1342
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
1343
+ {...(filtersHeaderReserve !== undefined ? { filtersHeaderReserve } : {})}
1163
1344
  {...(resultsHeader !== undefined ? { resultsHeader } : {})}
1164
1345
  {...(resultsLead !== undefined ? { resultsLead } : {})}
1165
1346
  {...(categoryFilter !== undefined ? { categoryFilter } : {})}
@@ -1175,6 +1356,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1175
1356
  {...(railTop !== undefined ? { railTop } : {})}
1176
1357
  {...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
1177
1358
  {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
1359
+ {...(filtersOpen !== undefined ? { filtersOpen } : {})}
1360
+ {...(onFiltersOpenChange !== undefined ? { onFiltersOpenChange } : {})}
1178
1361
  {...(pageSize !== undefined ? { pageSize } : {})}
1179
1362
  {...(breadcrumb !== undefined ? { breadcrumb } : {})}
1180
1363
  {...(wrapResults !== undefined ? { wrapResults } : {})}
@@ -50,6 +50,9 @@ export {
50
50
  export type {
51
51
  SearchPageProps,
52
52
  SearchFiltersLayout,
53
+ SearchFiltersHeader,
54
+ SearchFiltersHeaderSlotProps,
55
+ SearchFiltersOpenReason,
53
56
  SearchRailFrom,
54
57
  } from "./SearchPage.js";
55
58
 
@@ -122,10 +125,15 @@ export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
122
125
  export {
123
126
  FacetGroupControl,
124
127
  facetGroupIsEmptyHeading,
128
+ facetGroupReservedHeight,
125
129
  facetGroupShape,
126
130
  facetOptionNodes,
127
131
  isDictionaryFacet,
128
132
  FACET_DICTIONARY_THRESHOLD,
133
+ FACET_HEADING_HEIGHT,
134
+ FACET_OPTION_ROW_HEIGHT,
135
+ FACET_PILLS_PER_ROW,
136
+ FACET_PILL_ROW_HEIGHT,
129
137
  FACET_SHEET_PAGE,
130
138
  FACET_VISIBLE_OPTIONS,
131
139
  } from "./FacetGroupControl.js";