@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.
- package/CHANGELOG.md +99 -0
- package/dist/default/FacetGroupControl.d.ts +45 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +132 -2
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +25 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +20 -6
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +24 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +21 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +11 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +20 -1
- package/dist/headless/FacetPanel.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +170 -1
- package/src/default/FacetPanelPane.tsx +63 -4
- package/src/default/SearchPage.tsx +60 -2
- package/src/default/index.ts +5 -0
- package/src/headless/FacetPanel.tsx +31 -1
|
@@ -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
|
|
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
|
-
{
|
|
827
|
-
|
|
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={
|
|
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 =
|
|
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
|
-
{
|
|
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 } : {})}
|
package/src/default/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|