@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.
- package/CHANGELOG.md +88 -0
- package/dist/default/FacetGroupControl.d.ts +32 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +101 -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 +15 -5
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +108 -2
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +45 -11
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- 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/llms.txt +2 -2
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +120 -2
- package/src/default/FacetPanelPane.tsx +45 -3
- package/src/default/SearchPage.tsx +193 -10
- package/src/default/index.ts +8 -0
|
@@ -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
|
|
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
|
-
{
|
|
840
|
-
|
|
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={
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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 } : {})}
|
package/src/default/index.ts
CHANGED
|
@@ -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";
|