@stapel/search-react 0.33.0 → 0.34.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.
@@ -120,11 +120,11 @@ export const RESULTS_MAX_WIDTH = 1400;
120
120
  * pane's own breakpoint restated in a media query — three rules aimed at a
121
121
  * shape the pane could change under them at any release.
122
122
  *
123
- * It is the same class in BOTH header shapes, and in both it names the row that
124
- * would pin: the WIDE header block (heading, count and toolbar on one line
125
- * the block IS the row) and, in `header="compact"`, the toolbar alone, because
126
- * pinning the compact stack would put ~112px of chrome under a 56px header on a
127
- * 390px screen.
123
+ * It is the same class in BOTH header shapes, and in both it names THE SAME
124
+ * THING: the controls' own line, and never the heading beside it (D452). The
125
+ * compact shape always drew it that way; the wide shape did not, and pinning a
126
+ * block that carried the `<h1>` pinned 100px of chrome at 1440 and 150px at
127
+ * 1280 — see `WIDE_HEADING_ROW`.
128
128
  */
129
129
  export const RESULTS_TOOLBAR_CLASS = "stapel-search-results-toolbar";
130
130
 
@@ -173,6 +173,49 @@ const TOOLBAR_ROW: CSSProperties = {
173
173
  */
174
174
  const COMPACT_STACK: CSSProperties = { display: "contents" };
175
175
 
176
+ /**
177
+ * The WIDE shape's heading row — a row of its own, at the results column's
178
+ * full measure (D452).
179
+ *
180
+ * The heading used to be the leading flex item of the block that pins, with
181
+ * the controls as the trailing one. Two things followed, and both were
182
+ * measured on the stand:
183
+ *
184
+ * - the controls row is `nowrap` and holds a count, a sort select, a page
185
+ * size and a view switch, so it took whatever width it needed and left the
186
+ * heading **415px** of a 1200px column. An `<h1>` at 42px (50 at 1280) then
187
+ * wrapped to TWO LINES;
188
+ * - those two lines were inside the pinned box, so what stood under the
189
+ * header while the feed scrolled was **100px** of chrome at 1440 and
190
+ * **150px** at 1280 — a third of the fold on a laptop.
191
+ *
192
+ * Nothing about the heading belongs in a pinned bar: it names the page once,
193
+ * it is read once, and it is the one element on the page whose length the pair
194
+ * does not control (`resultsHeading` is the host's sentence — "Buy a
195
+ * Samsung Galaxy S23 in Kazan"). So it takes its own line at the full width,
196
+ * where it has room not to wrap, and the pin acts on the controls alone —
197
+ * which is what `header="compact"` already did.
198
+ *
199
+ * `100%` is DECLARED rather than left to the column's `align-items: stretch`:
200
+ * the row is what a host reads to know the heading is not in the pinned box,
201
+ * and a width that only happens to be full is not a contract.
202
+ */
203
+ const WIDE_HEADING_ROW: CSSProperties = { inlineSize: "100%", minInlineSize: 0 };
204
+
205
+ /** The wide heading itself: no margin of its own — the column's gap is the
206
+ * rhythm — and free to shrink inside its row rather than forcing it wider. */
207
+ const WIDE_HEADING: CSSProperties = { margin: 0, minInlineSize: 0 };
208
+
209
+ /**
210
+ * The wide row's trailing group — the surface's controls.
211
+ *
212
+ * `flex: 0 0 auto`: the count on the leading end is the elastic half (it is
213
+ * one short string and it arrives late), and the controls keep every pixel
214
+ * they need. Both ends shrinking is how a sort select ends up narrower than
215
+ * its own longest option.
216
+ */
217
+ const TOOLBAR_END: CSSProperties = { flex: "0 0 auto" };
218
+
176
219
  /** The compact shape's toolbar box — the row the pin acts on. */
177
220
  const COMPACT_TOOLBAR: CSSProperties = {
178
221
  display: "flex",
@@ -692,29 +735,35 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
692
735
  <Count bag={bag} />
693
736
  </Flex>
694
737
  ) : (
695
- /* The wide shape's header block IS the toolbar row: one line,
696
- heading at one end, count and controls at the other, and a
697
- direct child of the results column so it pins as it stands. */
698
- <Flex
699
- justify="space-between"
700
- align="center"
701
- gap={spacing[2]}
702
- className={RESULTS_TOOLBAR_CLASS}
703
- data-testid="search-results-toolbar"
704
- {...(toolbarPin !== undefined ? { style: toolbarPin } : {})}
705
- >
706
- <Typography.Title
707
- level={props.headingLevel ?? 4}
708
- style={{ margin: 0, minInlineSize: 0 }}
709
- data-testid="search-results-heading"
738
+ /* D452 the heading is its OWN row, at the column's full width,
739
+ and the row that pins is the controls. See `WIDE_HEADING_ROW`.
740
+ Both are direct children of the results column: the toolbar
741
+ has a parent as tall as the feed to travel in, and the heading
742
+ has the whole measure to set its line in. */
743
+ <>
744
+ <div style={WIDE_HEADING_ROW} data-testid="search-results-heading-row">
745
+ <Typography.Title
746
+ level={props.headingLevel ?? 4}
747
+ style={WIDE_HEADING}
748
+ data-testid="search-results-heading"
749
+ >
750
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
751
+ </Typography.Title>
752
+ </div>
753
+ <Flex
754
+ justify="space-between"
755
+ align="center"
756
+ gap={spacing[3]}
757
+ className={RESULTS_TOOLBAR_CLASS}
758
+ data-testid="search-results-toolbar"
759
+ style={{ ...TOOLBAR_ROW, ...toolbarPin }}
710
760
  >
711
- {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
712
- </Typography.Title>
713
- <Flex align="center" gap={spacing[3]} style={TOOLBAR_ROW}>
714
761
  <Count bag={bag} />
715
- {props.toolbar}
762
+ <Flex align="center" gap={spacing[3]} style={TOOLBAR_END}>
763
+ {props.toolbar}
764
+ </Flex>
716
765
  </Flex>
717
- </Flex>
766
+ </>
718
767
  )}
719
768
 
720
769
  <DegradationNotice
@@ -29,6 +29,17 @@ export interface FacetPanelBag {
29
29
  * category) — it is NOT what a failed query looks like.
30
30
  */
31
31
  readonly state: LoadState<readonly FacetGroup[]>;
32
+ /**
33
+ * The groups in hand belong to the PREVIOUS answer, and a newer one is in
34
+ * flight — `LoadReady.refreshing`, lifted out of the state so a skin can
35
+ * read it without narrowing the union first.
36
+ *
37
+ * A skin holds its geometry still while this is `true`: the axes are about
38
+ * to change, and a rail that resizes group by group as they arrive is the
39
+ * 0.0586 CLS a partition press measured (p43). It is never `true` on a
40
+ * first load — there is nothing to hold still then.
41
+ */
42
+ readonly refreshing: boolean;
32
43
  /**
33
44
  * `true` when the counts came from a SAMPLE because the candidate set
34
45
  * exceeded the backend's cap. The panel must say so — the spec makes this
@@ -228,7 +239,23 @@ export function useFacetPanel(props: {
228
239
  props.enabled !== undefined ? { enabled: props.enabled } : undefined
229
240
  );
230
241
 
231
- const envelope = loadStateFromQuery(query);
242
+ /*
243
+ * KEEP THE PREVIOUS ANSWER, AND SAY SO (p43, CLS 0.0586 on a partition press).
244
+ *
245
+ * `useSearchQuery` already runs with `placeholderData: keepPreviousData`, so
246
+ * the data in hand during a key change is the previous answer's — the panel
247
+ * has been drawing it for three releases. What it could not do is TELL a skin
248
+ * that this is what it was doing, so the rail resized group by group as the
249
+ * new axis's facets landed: `facet-group-make` and `facet-group-model` moved
250
+ * their neighbours as their option counts changed under them.
251
+ *
252
+ * `keepPrevious` reads TanStack's own `isPlaceholderData` into
253
+ * `LoadReady.refreshing`, which every projection below carries across
254
+ * (`mapLoad`, and `useHostFacetLabels` through it). It is set on EVERY ready
255
+ * answer once asked for, `false` included, so a skin keying its DOM off it
256
+ * does not grow and drop a wrapper as the flag comes and goes.
257
+ */
258
+ const envelope = loadStateFromQuery(query, { keepPrevious: true });
232
259
  const meta = envelope.status === "ready" ? envelope.data.facet_meta : EMPTY_META;
233
260
 
234
261
  const groups = mapLoad(envelope, (data) =>
@@ -271,6 +298,9 @@ export function useFacetPanel(props: {
271
298
 
272
299
  return {
273
300
  state: labelled,
301
+ // Read off the ANSWER's own state rather than off the projection, so a
302
+ // label pass that returns the groups untouched cannot lose it.
303
+ refreshing: envelope.status === "ready" && envelope.refreshing === true,
274
304
  approximate: meta.approximate,
275
305
  skipped: meta.skipped,
276
306
  counted: meta.counted,