@stapel/search-react 0.35.0 → 0.36.1

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.
@@ -80,6 +80,7 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
80
80
  import { FacetPanelPane } from "./FacetPanelPane.js";
81
81
  import type {
82
82
  CategoryFilterSlotProps,
83
+ FacetPanelPaneProps,
83
84
  GeoFilterSlotProps,
84
85
  } from "./FacetPanelPane.js";
85
86
  import { FilterChips } from "./FilterChips.js";
@@ -107,6 +108,54 @@ import type { ThemeModeProp } from "./types.js";
107
108
  /** Where the filters live: beside the results, or behind a button in a sheet. */
108
109
  export type SearchFiltersLayout = "column" | "sheet";
109
110
 
111
+ /**
112
+ * WHY the filter sheet's open state moved — handed to `onFiltersOpenChange`
113
+ * beside the new value.
114
+ *
115
+ * A host that only mirrors the boolean can ignore it. A host that ACTS on the
116
+ * close cannot: "the person pressed Show results" and "the person swiped the
117
+ * sheet away" are the same `false` and not the same event, and a container
118
+ * that logs one as the other reports an intent nobody had.
119
+ *
120
+ * - `open` — the sheet was asked for (the all-filters chip, the location
121
+ * row's door).
122
+ * - `apply` — the footer's "Show N results" committed and closed it.
123
+ * - `dismiss` — the dialog itself closed: the X, the scrim, Escape.
124
+ * - `consumer` — the host closed it through `filtersHeader`'s `closeFilters`.
125
+ */
126
+ export type SearchFiltersOpenReason = "open" | "apply" | "dismiss" | "consumer";
127
+
128
+ /** What `filtersHeader` is handed when a host passes a function. */
129
+ export interface SearchFiltersHeaderSlotProps {
130
+ /**
131
+ * Shut the sheet, with the reason `"consumer"`.
132
+ *
133
+ * This is the whole point of the callback form: a control in this slot that
134
+ * NAVIGATES on a press (a partition or axis chip that changes the route)
135
+ * has to close the sheet on that same press, or the next page opens under a
136
+ * drawer that is still up. Without it a host had to lift the open state out
137
+ * of the page — which it could not, because the page only offered
138
+ * `defaultFiltersOpen`.
139
+ *
140
+ * Harmless in the column layout, where there is no sheet to shut: the page
141
+ * still reports the change, and nothing moves on screen.
142
+ */
143
+ readonly closeFilters: () => void;
144
+ /** Is the sheet open around this header right now. */
145
+ readonly open: boolean;
146
+ }
147
+
148
+ /**
149
+ * The `filtersHeader` slot: a node, or a function told the sheet's state.
150
+ *
151
+ * The node form is what it has always been. The function form exists so a
152
+ * header can close the sheet it is inside without the host owning the open
153
+ * state — see {@link SearchFiltersHeaderSlotProps.closeFilters}.
154
+ */
155
+ export type SearchFiltersHeader =
156
+ | ReactNode
157
+ | ((slot: SearchFiltersHeaderSlotProps) => ReactNode);
158
+
110
159
  /**
111
160
  * WHERE the filter rail earns its 280px, when the token `tablet` edge is the
112
161
  * wrong place to draw it.
@@ -413,6 +462,22 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
413
462
  * saves less than folding a column's does. Set it to override either.
414
463
  */
415
464
  readonly visibleGroups?: number | null;
465
+ /**
466
+ * The filter panel's footer — what the filters DID (the live count) and the
467
+ * way out of them — see {@link FacetPanelPaneProps.footerBar}.
468
+ *
469
+ * Defaulted PER LAYOUT: `"static"` in the desktop COLUMN (the rail scrolls
470
+ * with the page, and a bar pinned to the port's floor sat on top of the last
471
+ * groups), and none in the phone SHEET, whose own "Show N results" footer is
472
+ * already the count AND the exit.
473
+ *
474
+ * It is a PROP because it could not be reached any other way: the bar writes
475
+ * its own `display` inline, so a host stylesheet cannot suppress it without
476
+ * `!important`, and this page hard-coded the column's value. A surface that
477
+ * draws its own count under the rail passes `false`; one that wants the bar
478
+ * inside the sheet as well passes `"sticky"`.
479
+ */
480
+ readonly footerBar?: FacetPanelPaneProps["footerBar"];
416
481
  /** Print the engine's list of uncounted facet slugs in the filter panel.
417
482
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
418
483
  readonly skippedNotice?: boolean;
@@ -437,8 +502,12 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
437
502
  * for. Whatever a host renders here reads and writes the same URL state as
438
503
  * the facets beside it (`useSearchState()`), so it is a filter in every
439
504
  * sense that matters and not a decoration bolted on top.
505
+ *
506
+ * A FUNCTION is handed `{ closeFilters, open }` — for a header whose own
507
+ * control navigates away, which on a phone has to take the sheet down with
508
+ * it. See {@link SearchFiltersHeaderSlotProps}.
440
509
  */
441
- readonly filtersHeader?: ReactNode;
510
+ readonly filtersHeader?: SearchFiltersHeader;
442
511
  /**
443
512
  * The block-size that slot will END UP at, declared before it has anything
444
513
  * in it — a number in CSS pixels or any length (`"96px"`, `"6rem"`).
@@ -624,9 +693,47 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
624
693
  * from a category page), and for the story that photographs the sheet —
625
694
  * a state reached only by a tap is a state nothing outside a browser has
626
695
  * ever seen. The person still closes it; this is the initial value, not a
627
- * controlled one.
696
+ * controlled one — pass `filtersOpen` for that.
628
697
  */
629
698
  readonly defaultFiltersOpen?: boolean;
699
+ /**
700
+ * The sheet's open state, OWNED BY THE HOST.
701
+ *
702
+ * React's usual contract: pass this and the page stops keeping its own copy
703
+ * — every open and every close is a call to `onFiltersOpenChange` and
704
+ * nothing moves until the value comes back. Leave it out and the page is
705
+ * uncontrolled exactly as before, `defaultFiltersOpen` its initial value.
706
+ *
707
+ * It exists because a control the host renders INSIDE the sheet can end the
708
+ * search that sheet belongs to. A partition chip in `filtersHeader` that
709
+ * navigates leaves the drawer standing over the page it opened, and the
710
+ * host had no handle on the state to shut it — the page exposed the initial
711
+ * value and nothing else. A host that only needs the close and not the
712
+ * state can take `filtersHeader`'s `closeFilters` instead and keep the page
713
+ * uncontrolled.
714
+ *
715
+ * ```tsx
716
+ * const [filtersOpen, setFiltersOpen] = useState(false);
717
+ * <SearchPage
718
+ * filtersOpen={filtersOpen}
719
+ * onFiltersOpenChange={(open) => { setFiltersOpen(open); }}
720
+ * filtersHeader={<PartitionChips onPick={(href) => { setFiltersOpen(false); navigate(href); }} />}
721
+ * />
722
+ * ```
723
+ */
724
+ readonly filtersOpen?: boolean;
725
+ /**
726
+ * Told that the sheet wants to open or close, and WHY — see
727
+ * {@link SearchFiltersOpenReason}.
728
+ *
729
+ * Called in both modes, controlled and not: a host that wants to watch the
730
+ * sheet (an analytics event on the dismiss, a scroll lock of its own) does
731
+ * not have to take ownership of the state to hear about it.
732
+ */
733
+ readonly onFiltersOpenChange?: (
734
+ open: boolean,
735
+ reason: SearchFiltersOpenReason
736
+ ) => void;
630
737
  /** Offer a page-size control beside the sort. Default `true`. */
631
738
  readonly pageSize?: boolean;
632
739
  /**
@@ -702,6 +809,7 @@ interface SearchPageBodyProps {
702
809
  readonly resultsLead?: ReactNode;
703
810
  readonly dictionaryMode?: "field" | "inline" | "sheet";
704
811
  readonly visibleGroups?: number | null;
812
+ readonly footerBar?: FacetPanelPaneProps["footerBar"];
705
813
  readonly categoryFeatures?: readonly FeatureDef[];
706
814
  readonly categoryFeaturesPending?: boolean;
707
815
  readonly filtersHeaderReserve?: number | string;
@@ -717,7 +825,7 @@ interface SearchPageBodyProps {
717
825
  readonly geoLabel?: ReactNode;
718
826
  readonly skippedNotice?: boolean;
719
827
  readonly footer?: ReactNode;
720
- readonly filtersHeader?: ReactNode;
828
+ readonly filtersHeader?: SearchFiltersHeader;
721
829
  readonly resultsHeader?: ReactNode;
722
830
  readonly appliedChips?: boolean | "desktop";
723
831
  readonly otherCategories?: boolean;
@@ -731,6 +839,11 @@ interface SearchPageBodyProps {
731
839
  readonly railTop?: number | string;
732
840
  readonly stickyToolbar?: SearchToolbarPin;
733
841
  readonly defaultFiltersOpen?: boolean;
842
+ readonly filtersOpen?: boolean;
843
+ readonly onFiltersOpenChange?: (
844
+ open: boolean,
845
+ reason: SearchFiltersOpenReason
846
+ ) => void;
734
847
  readonly pageSize?: boolean;
735
848
  readonly breadcrumb?: ReactNode;
736
849
  readonly wrapResults?: SearchResultsWrapper;
@@ -767,7 +880,36 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
767
880
  : surface === "sheet"
768
881
  ? "sheet"
769
882
  : "column");
770
- const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
883
+ /**
884
+ * The rail's footer bar, defaulted PER LAYOUT and overridable — see
885
+ * {@link SearchPageProps.footerBar}.
886
+ *
887
+ * STATIC, not sticky, in the column: the rail scrolls with the page, and a
888
+ * bar pinned to the port's floor sat on top of the last groups. None in the
889
+ * sheet, whose own "Show N results" footer is already the count and the exit.
890
+ */
891
+ const footerBar: FacetPanelPaneProps["footerBar"] =
892
+ props.footerBar ?? (layout === "sheet" ? undefined : "static");
893
+ // Controlled or not, decided by the PRESENCE of `filtersOpen` and read once
894
+ // per render — the state the page keeps is only ever the uncontrolled half,
895
+ // and a controlled host's value is never copied into it (copying it is how
896
+ // a controlled component starts disagreeing with its owner one frame after
897
+ // the owner refuses a change).
898
+ const controlledFiltersOpen = props.filtersOpen;
899
+ const [ownFiltersOpen, setOwnFiltersOpen] = useState(
900
+ props.defaultFiltersOpen === true
901
+ );
902
+ const sheetOpen = controlledFiltersOpen ?? ownFiltersOpen;
903
+ const { onFiltersOpenChange } = props;
904
+ const setSheetOpen = (next: boolean, reason: SearchFiltersOpenReason): void => {
905
+ if (controlledFiltersOpen === undefined) {
906
+ setOwnFiltersOpen(next);
907
+ }
908
+ onFiltersOpenChange?.(next, reason);
909
+ };
910
+ const closeFilters = (): void => {
911
+ setSheetOpen(false, "consumer");
912
+ };
771
913
 
772
914
  // How the results are ARRANGED. Component state, not URL state: it changes
773
915
  // how the same answer is drawn, never what the answer is, so it must not
@@ -857,7 +999,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
857
999
  ? { style: { minBlockSize: props.filtersHeaderReserve } }
858
1000
  : {})}
859
1001
  >
860
- {filtersHeader}
1002
+ {/* The function form is called HERE, inside the slot it fills, so a
1003
+ header that reads `open` re-renders with the sheet. Presence is
1004
+ still decided by the PROP above and never by what the function
1005
+ returned: a header that renders nothing this frame must not
1006
+ collapse the box it reserved. */}
1007
+ {typeof filtersHeader === "function"
1008
+ ? filtersHeader({ closeFilters, open: sheetOpen })
1009
+ : filtersHeader}
861
1010
  </div>
862
1011
  )}
863
1012
  {/* The facet panel is skipped entirely when the only thing it would draw
@@ -873,11 +1022,8 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
873
1022
  result count scrolled out of sight above the fold. */}
874
1023
  {filtersEmpty ? null : (
875
1024
  <FacetPanelPane
876
- {...(layout === "sheet"
877
- ? { heading: null }
878
- : // STATIC, not sticky: the rail scrolls with the page, and a bar
879
- // pinned to the port's floor sat on top of the last groups.
880
- { footerBar: "static" as const })}
1025
+ {...(layout === "sheet" ? { heading: null } : {})}
1026
+ {...(footerBar !== undefined ? { footerBar } : {})}
881
1027
  dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "sheet" : "field")}
882
1028
  // `??` would treat an explicit `null` ("never fold") the same as
883
1029
  // "not set": `visibleGroups` uses `null` as a real value, unlike
@@ -1036,7 +1182,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1036
1182
  // has the whole panel on screen beside this row.
1037
1183
  filtersDoor={layout === "sheet"}
1038
1184
  onOpenAll={() => {
1039
- setSheetOpen(true);
1185
+ setSheetOpen(true, "open");
1040
1186
  }}
1041
1187
  />
1042
1188
  )}
@@ -1073,7 +1219,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1073
1219
  still the whole panel, for the person who wants all of it. */}
1074
1220
  <FilterChips
1075
1221
  onOpenAll={() => {
1076
- setSheetOpen(true);
1222
+ setSheetOpen(true, "open");
1077
1223
  }}
1078
1224
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
1079
1225
  {...(locale !== undefined ? { locale } : {})}
@@ -1092,7 +1238,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1092
1238
  <SkinDialog
1093
1239
  open={sheetOpen}
1094
1240
  onClose={() => {
1095
- setSheetOpen(false);
1241
+ setSheetOpen(false, "dismiss");
1096
1242
  }}
1097
1243
  title={t(SEARCH_I18N_KEYS.facetsTitle)}
1098
1244
  dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
@@ -1105,7 +1251,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1105
1251
  data-analytics="none"
1106
1252
  data-analytics-reason="the filters are already applied; this closes the sheet"
1107
1253
  onClick={() => {
1108
- setSheetOpen(false);
1254
+ setSheetOpen(false, "apply");
1109
1255
  }}
1110
1256
  >
1111
1257
  {applyLabel}
@@ -1175,6 +1321,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1175
1321
  railTop,
1176
1322
  stickyToolbar,
1177
1323
  defaultFiltersOpen,
1324
+ filtersOpen,
1325
+ onFiltersOpenChange,
1178
1326
  pageSize,
1179
1327
  breadcrumb,
1180
1328
  wrapResults,
@@ -1187,6 +1335,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1187
1335
  resultsHeadingVisible,
1188
1336
  dictionaryMode,
1189
1337
  visibleGroups,
1338
+ footerBar,
1190
1339
  pinnedFacets,
1191
1340
  mode,
1192
1341
  ...parseOptions
@@ -1201,6 +1350,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1201
1350
  {...(resultsHeadingVisible !== undefined ? { resultsHeadingVisible } : {})}
1202
1351
  {...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
1203
1352
  {...(visibleGroups !== undefined ? { visibleGroups } : {})}
1353
+ {...(footerBar !== undefined ? { footerBar } : {})}
1204
1354
  {...(pinnedFacets !== undefined ? { pinnedFacets } : {})}
1205
1355
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
1206
1356
  {...(categoryFeaturesPending !== undefined
@@ -1233,6 +1383,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1233
1383
  {...(railTop !== undefined ? { railTop } : {})}
1234
1384
  {...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
1235
1385
  {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
1386
+ {...(filtersOpen !== undefined ? { filtersOpen } : {})}
1387
+ {...(onFiltersOpenChange !== undefined ? { onFiltersOpenChange } : {})}
1236
1388
  {...(pageSize !== undefined ? { pageSize } : {})}
1237
1389
  {...(breadcrumb !== undefined ? { breadcrumb } : {})}
1238
1390
  {...(wrapResults !== undefined ? { wrapResults } : {})}
@@ -216,6 +216,24 @@ const WIDE_HEADING: CSSProperties = { margin: 0, minInlineSize: 0 };
216
216
  */
217
217
  const TOOLBAR_END: CSSProperties = { flex: "0 0 auto" };
218
218
 
219
+ /**
220
+ * The wide row's LEADING half, and it is always in the row (D466).
221
+ *
222
+ * The count arrives with the answer, one render after the toolbar is already
223
+ * on screen, and `<Count>` renders nothing at all until it does — so a row
224
+ * spaced by `justify: space-between` had ONE item in the first frame and TWO
225
+ * in the second, which moved the sort/view control from the leading edge to
226
+ * the trailing one as the number landed: x 328→459 at 1280, 564→863 at 1920,
227
+ * 312→796 at 1100, and the same jump on a seller's page.
228
+ *
229
+ * A leading box that is always there, and grows, is what fixes it: the
230
+ * trailing group's x is then `row right − its own width` in every frame,
231
+ * whatever the count says or whether it says anything. `min-inline-size: 0`
232
+ * lets a long count shrink into the space that is left rather than push the
233
+ * controls, which the row's own `overflow-x` then scrolls.
234
+ */
235
+ const TOOLBAR_LEAD: CSSProperties = { flex: "1 1 auto", minInlineSize: 0 };
236
+
219
237
  /** The compact shape's toolbar box — the row the pin acts on. */
220
238
  const COMPACT_TOOLBAR: CSSProperties = {
221
239
  display: "flex",
@@ -751,14 +769,19 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
751
769
  </Typography.Title>
752
770
  </div>
753
771
  <Flex
754
- justify="space-between"
755
772
  align="center"
756
773
  gap={spacing[3]}
757
774
  className={RESULTS_TOOLBAR_CLASS}
758
775
  data-testid="search-results-toolbar"
759
776
  style={{ ...TOOLBAR_ROW, ...toolbarPin }}
760
777
  >
761
- <Count bag={bag} />
778
+ {/* The elastic half, present whether or not there is a
779
+ count in it — see TOOLBAR_LEAD. It is what holds the
780
+ controls against the trailing edge in the frame before
781
+ the number arrives, so nothing travels when it does. */}
782
+ <div style={TOOLBAR_LEAD} data-testid="search-results-toolbar-lead">
783
+ <Count bag={bag} />
784
+ </div>
762
785
  <Flex align="center" gap={spacing[3]} style={TOOLBAR_END}>
763
786
  {props.toolbar}
764
787
  </Flex>
@@ -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