@stapel/search-react 0.43.0 → 0.45.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.
@@ -29,7 +29,7 @@
29
29
  import type { CSSProperties, ReactElement, ReactNode } from "react";
30
30
  import { Button, Flex, Typography } from "antd";
31
31
  import { useT } from "@stapel/core";
32
- import { breakpoints, spacing } from "@stapel/tokens";
32
+ import { spacing } from "@stapel/tokens";
33
33
  import {
34
34
  POINTER_FOCUS,
35
35
  POINTER_FOCUS_STYLE_HREF,
@@ -41,79 +41,63 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
41
41
  /** How many values the block prints before the link into the full control. */
42
42
  export const POPULAR_VALUES_LIMIT = 12;
43
43
 
44
- /** How many columns the list flows into. Three fills a desktop content column
45
- * without turning a make into a two-line wrap. */
44
+ /** How many columns the list flows into when a host names a number. Three
45
+ * fills a desktop content column without turning a make into a two-line wrap. */
46
46
  export const POPULAR_VALUES_COLUMNS = 3;
47
47
 
48
48
  /**
49
- * The ceiling on columns, whatever a host or the ladder asks for.
49
+ * The ceiling on columns a HOST may ask for.
50
50
  *
51
- * Past four the block stops being a table of contents and becomes a grid of
52
- * two-word cells: each column is one make and one number, and a fifth column
53
- * on a 1100px results pane puts 60px of air between «Toyota 4» and the next
54
- * make. Four is where the density still reads as a list.
51
+ * Past four a block whose width is fixed by its words stops being a table of
52
+ * contents and becomes a grid of two-word cells: each column is one make and
53
+ * one number, and a fifth column puts 60px of air between «Toyota 4» and the
54
+ * next make. Four is where that density still reads as a list.
55
+ *
56
+ * It governs the numeric arm ONLY, and since 0.45 that is the whole of its
57
+ * job. The responsive arm has no count to cap and needs none — see
58
+ * {@link POPULAR_VALUE_COLUMN_WIDTH}: there the MEASURE is the ceiling, so a
59
+ * column can never be narrower than a value and its count however many of them
60
+ * the pane holds.
55
61
  */
56
62
  export const POPULAR_VALUES_MAX_COLUMNS = 4;
57
63
 
58
64
  /**
59
- * The width one column of this block needs: a make and its count, on one line,
60
- * at the default type step. «Ford 1 204» is the measure; below it a value
61
- * wraps under its own number and the block stops being scannable.
62
- */
63
- export const POPULAR_VALUE_COLUMN_WIDTH = 200;
64
-
65
- /** The class the container query is hung on. */
66
- export const POPULAR_VALUES_CLASS = "stapel-popular-values";
67
-
68
- /** The `href` the hoisted ladder sheet is deduplicated by (React 19). */
69
- export const POPULAR_VALUES_STYLE_HREF = "stapel-popular-values";
70
-
71
- /**
72
- * The ladder `columns="responsive"` climbs — one rung per column, by the width
73
- * of the BLOCK rather than of the window.
65
+ * The minimum measure one column of this block needs, and the whole of the
66
+ * responsive arm's layout input.
67
+ *
68
+ * `column-width` is a MINIMUM, not a width: the browser fits as many columns
69
+ * of at least this measure as the element's own width allows and then widens
70
+ * them to fill it. That is what makes the number a ceiling on density as well
71
+ * as a floor on legibility no pane, however wide, can produce a column
72
+ * narrower than this.
73
+ *
74
+ * ── 150, and it is measured rather than reasoned ──────────────────────────
74
75
  *
75
- * The container is what decides, and it has to be: this block sits in the
76
- * results column, which on a 1440px desktop is the window minus a 280px rail
77
- * minus the gap. A media query would give it four columns at a width it never
78
- * has, and one column inside a narrow host container that happens to sit on a
79
- * wide screen. `container-type: inline-size` plus `@container` asks the only
80
- * question that has an answer here — how wide is this block.
76
+ * It was 200, declared as the measure «Ford 1 204» needs. Swept in a real
77
+ * browser over the live twelve makes and over a worst case of the longest make
78
+ * on the stand with a four-digit count, at this gap:
81
79
  *
82
- * The rungs: one column while the block is narrower than two of
83
- * {@link POPULAR_VALUE_COLUMN_WIDTH}, then the two token breakpoints for the
84
- * third and fourth. `tablet` and `desktop` are `@stapel/tokens`' own numbers,
85
- * and using them here is the same statement they make everywhere else — this
86
- * is where a layout of this density gains a column.
80
+ * 200 4 columns in the storefront's 1088px pane, 3 rows
81
+ * 170 4 columns, 3 rows
82
+ * 160 4 columns, 3 rows
83
+ * 150 6 columns, 2 rows
84
+ *
85
+ * 150 is the knee, and nothing wraps at it: because the browser widens the
86
+ * columns to fill, the USED column is 155px in that pane and never below 152px
87
+ * at any width swept. The old claim that a value wraps under its own number
88
+ * below 200 does not hold at the default type step.
87
89
  */
88
- export const POPULAR_VALUES_LADDER: readonly {
89
- readonly minInlineSize: number;
90
- readonly columns: number;
91
- }[] = [
92
- { minInlineSize: POPULAR_VALUE_COLUMN_WIDTH * 2, columns: 2 },
93
- { minInlineSize: breakpoints.tablet, columns: 3 },
94
- { minInlineSize: breakpoints.desktop, columns: 4 },
95
- ];
90
+ export const POPULAR_VALUE_COLUMN_WIDTH = 150;
96
91
 
97
92
  /**
98
- * The ladder as CSS. One `@container` rule per rung, ascending, so the widest
99
- * matching rung is the one that wins by ordinary cascade order.
93
+ * The gutter between columns one step up the scale from the row's own, so a
94
+ * new column reads as a column rather than as a wrapped line.
100
95
  *
101
- * Emitted as a hoisted `<style>` rather than an inline style because a
102
- * container query is unreachable from one the same reason `<SearchPage>`
103
- * hoists its rail sheet.
96
+ * Exported because it is half of the arithmetic that decides how many columns
97
+ * a pane produces (`floor((available + gap) / (width + gap))`), and a host
98
+ * sizing a reservation for this block should not have to re-measure it.
104
99
  */
105
- export function popularValuesLadderCss(): string {
106
- const block = `.${POPULAR_VALUES_CLASS}`;
107
- return [
108
- `${block}{container-type:inline-size}`,
109
- `${block}>[data-popular-columns]{column-count:1}`,
110
- ...POPULAR_VALUES_LADDER.map(
111
- (rung) =>
112
- `@container (min-width: ${String(rung.minInlineSize)}px)` +
113
- `{${block}>[data-popular-columns]{column-count:${String(rung.columns)}}}`
114
- ),
115
- ].join("\n");
116
- }
100
+ export const POPULAR_VALUES_COLUMN_GAP: number = spacing[6];
117
101
 
118
102
  /** A value with no evidence behind it is not a popular value. Uncounted
119
103
  * options carry `count: null` and are dropped here rather than printed with a
@@ -133,32 +117,55 @@ export function popularOptions(
133
117
  }
134
118
 
135
119
  /**
136
- * The columns box: as wide as its WORDS, and no wider.
120
+ * The columns box, in its two arms.
121
+ *
122
+ * ── The responsive arm: a block, and a MEASURE ────────────────────────────
123
+ *
124
+ * `column-width` asks the element's own width how many columns fit. That is
125
+ * the whole mechanism — no container query, no rungs, no hoisted sheet, and no
126
+ * number that has to be kept in step with a layout this component cannot see.
127
+ * It is also what the expanded band next door already does, so the two arms of
128
+ * one control stop answering "how many columns" two different ways.
137
129
  *
138
- * Multi-column layout divides the container, so three columns of a block that
139
- * is handed the whole results pane are three ~360px columns holding «Chery
140
- * which on the storefront printed a make, then 300px of nothing, then the
141
- * next make. The owner read it as broken, and it is: nothing about this block
142
- * wants the pane's width, it wants its own.
130
+ * What it replaced was a container-query ladder whose rungs were the token
131
+ * WINDOW breakpoints and this block is the window less a 280px rail less the
132
+ * gap, so at 1440 it is 1088px wide and the rung that grants a fourth column
133
+ * (1200px) could never fire. The ladder's own doc argued that a media query
134
+ * would be wrong here for precisely that reason, and then used the window's
135
+ * numbers to measure the container.
143
136
  *
144
- * `inline-size: fit-content` is the whole fix. A multi-column box's max-content
145
- * size is `columns × (the widest item) + gaps`exactly the block's natural
146
- * measure and `fit-content` takes that unless the available space is
147
- * smaller, in which case the columns shrink instead of overflowing. The box
148
- * stays a block, so it stays flush with the pane's leading edge; the ladder in
149
- * the sheet still decides HOW MANY columns, and each one is now sized by the
150
- * longest make in it.
137
+ * `inline-size: fit-content` is deliberately NOT here, and its absence is
138
+ * load-bearing twice over. It sized the box to its words377px of a 1088px
139
+ * pane on the live storefront, 711px of white beside twelve values stacked
140
+ * four deep and it is measurably incompatible with a column measure: set
141
+ * together, the box collapses to a single column. The defect `fit-content` was
142
+ * added for (three ~360px columns each holding «Chery 5») cannot arise here,
143
+ * because a column is never wider than the pane divided by however many
144
+ * {@link POPULAR_VALUE_COLUMN_WIDTH} fit in it.
151
145
  *
152
- * The gap is one step up the scale from the row's own: at content width the
153
- * columns sit close enough that the old 16px read as a wrapped line rather
154
- * than as a new column.
146
+ * `column-fill: balance` spreads the rows evenly over the columns the width
147
+ * produces, rather than filling the first column to the box's block-size.
148
+ *
149
+ * ── The numeric arm: unchanged, words-wide ────────────────────────────────
150
+ *
151
+ * A host that names a number has decided its own layout, and a count divides
152
+ * the container — so three columns of a box handed the whole results pane are
153
+ * three ~360px columns holding «Chery 5», a make and then 300px of nothing.
154
+ * `fit-content` is the cure there and stays: the box takes its natural measure
155
+ * (`columns × widest item + gaps`) unless the space is smaller, in which case
156
+ * the columns shrink rather than overflow.
155
157
  */
156
158
  function COLUMNS(count: number | undefined): CSSProperties {
159
+ if (count === undefined) {
160
+ return {
161
+ columnWidth: POPULAR_VALUE_COLUMN_WIDTH,
162
+ columnGap: POPULAR_VALUES_COLUMN_GAP,
163
+ columnFill: "balance",
164
+ };
165
+ }
157
166
  return {
158
- // The rungs live in the sheet; an inline `column-count` would win against
159
- // every one of them, so the responsive arm states none.
160
- ...(count === undefined ? {} : { columnCount: count }),
161
- columnGap: spacing[6],
167
+ columnCount: count,
168
+ columnGap: POPULAR_VALUES_COLUMN_GAP,
162
169
  inlineSize: "fit-content",
163
170
  maxInlineSize: "100%",
164
171
  };
@@ -186,10 +193,12 @@ export interface PopularValuesProps {
186
193
  /**
187
194
  * How many columns. Default {@link POPULAR_VALUES_COLUMNS}.
188
195
  *
189
- * `"responsive"` climbs {@link POPULAR_VALUES_LADDER} by the width of the
190
- * BLOCK 1, 2, 3, 4 instead of taking one number for every surface. The
191
- * numeric form stays, and stays the default: a host that has already decided
192
- * its layout should not have that decision taken back by a query.
196
+ * `"responsive"` hands the question to the ELEMENT: native multicol over
197
+ * {@link POPULAR_VALUE_COLUMN_WIDTH} fits as many columns as this block's own
198
+ * width allows, at every width, with no rungs to keep in step with a layout
199
+ * this component cannot see. The numeric form stays, and stays the default:
200
+ * a host that has already decided its layout should not have that decision
201
+ * taken back.
193
202
  */
194
203
  readonly columns?: number | "responsive";
195
204
  /** The block's heading. Defaults to the group's own label; `null` draws
@@ -213,7 +222,6 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
213
222
  <Flex
214
223
  vertical
215
224
  gap={spacing[2]}
216
- {...(responsive ? { className: POPULAR_VALUES_CLASS } : {})}
217
225
  data-testid={`popular-values-${group.slug}`}
218
226
  data-label-source={group.labelSource}
219
227
  data-columns={responsive ? "responsive" : String(props.columns ?? POPULAR_VALUES_COLUMNS)}
@@ -222,19 +230,10 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
222
230
  <style href={POINTER_FOCUS_STYLE_HREF} precedence="default">
223
231
  {pointerFocusCss()}
224
232
  </style>
225
- {/* The ladder, hoisted and deduped by `href`: a container query has no
226
- inline form. Only where it is asked for — a block with a fixed
227
- column count needs no sheet at all. */}
228
- {responsive && (
229
- <style href={POPULAR_VALUES_STYLE_HREF} precedence="default">
230
- {popularValuesLadderCss()}
231
- </style>
232
- )}
233
233
  {props.heading !== null && (
234
234
  <Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
235
235
  )}
236
236
  <div
237
- {...(responsive ? { "data-popular-columns": "" } : {})}
238
237
  data-testid={`popular-columns-${group.slug}`}
239
238
  style={COLUMNS(
240
239
  responsive
@@ -189,13 +189,10 @@ export type { ColorAxisLike, TermExtra } from "./swatches.js";
189
189
  export {
190
190
  PopularValues,
191
191
  popularOptions,
192
- popularValuesLadderCss,
193
- POPULAR_VALUES_CLASS,
194
192
  POPULAR_VALUES_COLUMNS,
195
- POPULAR_VALUES_LADDER,
193
+ POPULAR_VALUES_COLUMN_GAP,
196
194
  POPULAR_VALUES_LIMIT,
197
195
  POPULAR_VALUES_MAX_COLUMNS,
198
- POPULAR_VALUES_STYLE_HREF,
199
196
  POPULAR_VALUE_COLUMN_WIDTH,
200
197
  } from "./PopularValues.js";
201
198
  export type { PopularValuesProps } from "./PopularValues.js";
package/src/index.ts CHANGED
@@ -137,6 +137,7 @@ export {
137
137
  facetGroupOfferableOptions,
138
138
  facetOptionIsOfferable,
139
139
  facetOptionLabel,
140
+ featureAllowsFaceting,
140
141
  isFacetableFeature,
141
142
  orderFacetGroupsBySchema,
142
143
  } from "./state/facets.js";
@@ -175,6 +175,39 @@ export const FACETABLE_FEATURE_TYPES: readonly string[] = [
175
175
  "select",
176
176
  ];
177
177
 
178
+ /**
179
+ * `config.facet: false` — the catalogue's own buyer-facet OPT-OUT (D74).
180
+ *
181
+ * A category's feature list is two documents at once: the seller's form and
182
+ * the buyer's filter panel. Most fields are honestly both. A few are neither —
183
+ * the parcel's weight, length, height and width, the wholesale block, the
184
+ * packing quantity — because they are commerce metadata the seller states
185
+ * about the SALE rather than axes anybody shops along. No library can infer
186
+ * that from the type: the very same `int` is a real axis one category over.
187
+ * So the catalogue says it, in one key, and `stapel-search` reads it
188
+ * (`_is_facetable`) off the FeatureDef and then off its `config`, defaulting
189
+ * to TRUE so a catalogue that says nothing keeps today's behaviour.
190
+ *
191
+ * This is that reader, spelled the same way and in the same order, because
192
+ * the client cannot always wait for the server to apply it. The discrete half
193
+ * is built from the ANSWER, which the engine has already filtered; the range
194
+ * half is built from the CATEGORY SCHEMA, which nobody has. On a live laptops
195
+ * leaf that difference was six from/to rows of shipping and wholesale
196
+ * paperwork over thirteen second-hand laptops, every one of the six carrying
197
+ * `facet: false` in the catalogue the page was rendering.
198
+ *
199
+ * Read through `config` as well as off the feature: the canon leaves `config`
200
+ * opaque and the boundary serving it may lift the key later, so both spellings
201
+ * answer today and the same code answers afterwards.
202
+ */
203
+ export function featureAllowsFaceting(feature: FeatureDef | undefined): boolean {
204
+ if (feature === undefined) return true;
205
+ for (const flag of [feature["facet"], featureConfig(feature)["facet"]]) {
206
+ if (flag !== undefined && flag !== null) return Boolean(flag);
207
+ }
208
+ return true;
209
+ }
210
+
178
211
  /**
179
212
  * Can a person filter by the slug this feature def describes?
180
213
  *
@@ -188,9 +221,17 @@ export const FACETABLE_FEATURE_TYPES: readonly string[] = [
188
221
  *
189
222
  * The same reasoning covers a def with no `config.type` at all: an untyped
190
223
  * feature is a def that says nothing, and nothing is not a verdict.
224
+ *
225
+ * `facet: false` is the one thing here that is not a type rule — it is the
226
+ * catalogue naming an axis and disowning it, which is exactly the shape this
227
+ * predicate already honours. The engine applies it before the buckets are
228
+ * sent, so on a current server this changes nothing; it is what answers a
229
+ * stale index or an older engine. An APPLIED filter still outranks it, in
230
+ * `buildFacetGroups`, the same way it outranks the type table.
191
231
  */
192
232
  export function isFacetableFeature(feature: FeatureDef | undefined): boolean {
193
233
  if (feature === undefined) return true;
234
+ if (!featureAllowsFaceting(feature)) return false;
194
235
  const type = featureType(feature);
195
236
  if (type === undefined) return true;
196
237
  return FACETABLE_FEATURE_TYPES.includes(type);
@@ -68,6 +68,7 @@ import type {
68
68
  SearchRange,
69
69
  } from "../api/types.js";
70
70
  import { withheldSlugs } from "../api/types.js";
71
+ import { featureAllowsFaceting } from "./facets.js";
71
72
 
72
73
  /**
73
74
  * Value types a numeric range row is drawn for (`config.type`, the
@@ -370,6 +371,12 @@ export function isRangeFeature(feature: FeatureDef): boolean {
370
371
  * seven numeric attributes a phone category happens to declare are all
371
372
  * shipping and wholesale inputs; the one number a phone buyer narrows by is
372
373
  * the price, and it belongs above them.
374
+ *
375
+ * That sentence stood here for several releases as an observation, and it was
376
+ * a BUG REPORT: those inputs are not the buyer's to see at all, and the
377
+ * catalogue had said so all along. `facet: false` is the opt-out and the loop
378
+ * below now reads it (`featureAllowsFaceting`) — see the note at the push.
379
+ * Ordering was the wrong cure for a row that should not be drawn.
373
380
  */
374
381
  export function buildRangeGroups(
375
382
  input: BuildRangeGroupsInput
@@ -393,6 +400,18 @@ export function buildRangeGroups(
393
400
  // server does with it (`index_schema.CORE_RANGE_FIELDS` reserves the
394
401
  // slug), so drawing both would put two controls over one filter.
395
402
  if (core.has(feature.slug)) continue;
403
+ // THE CATALOGUE'S OWN OPT-OUT, on the one path that never asked for it
404
+ // (D74). `facet: false` says the seller states this about the SALE — the
405
+ // parcel's weight, the minimum order — and on a live laptops leaf the six
406
+ // features carrying it were six of the seven from/to rows a buyer of
407
+ // second-hand laptops was offered. The discrete half is built from the
408
+ // ANSWER, which the engine has already filtered by this flag; this half is
409
+ // built from the raw schema, so it applies the flag itself. A slug this
410
+ // answer MEASURED is exempt — the server publishing bounds for it is the
411
+ // server saying it counted it — and a slug the URL constrains gets its row
412
+ // from the state loop below, so a stale link keeps the control that
413
+ // clears it.
414
+ if (!reported.has(feature.slug) && !featureAllowsFaceting(feature)) continue;
396
415
  // The schema's own numeric types, PLUS anything this answer measured: a
397
416
  // vocabulary-backed `year` is a choice in the catalogue and a from/to on
398
417
  // the page, and the server measuring it is the fact that settles it.