@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.
- package/CHANGELOG.md +92 -0
- package/MODULE.md +24 -3
- package/dist/default/PopularValues.d.ts +48 -44
- package/dist/default/PopularValues.d.ts.map +1 -1
- package/dist/default/PopularValues.js +87 -71
- package/dist/default/PopularValues.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +33 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +43 -0
- package/dist/state/facets.js.map +1 -1
- package/dist/state/ranges.d.ts +6 -0
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +20 -0
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/PopularValues.tsx +92 -93
- package/src/default/index.ts +1 -4
- package/src/index.ts +1 -0
- package/src/state/facets.ts +41 -0
- package/src/state/ranges.ts +19 -0
|
@@ -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 {
|
|
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
|
|
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
|
|
49
|
+
* The ceiling on columns a HOST may ask for.
|
|
50
50
|
*
|
|
51
|
-
* Past four
|
|
52
|
-
* two-word cells: each column is one make and
|
|
53
|
-
*
|
|
54
|
-
* make. Four is where
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
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
|
|
99
|
-
*
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
137
|
+
* `inline-size: fit-content` is deliberately NOT here, and its absence is
|
|
138
|
+
* load-bearing twice over. It sized the box to its words — 377px 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
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
159
|
-
|
|
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"`
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
package/src/default/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
package/src/state/facets.ts
CHANGED
|
@@ -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);
|
package/src/state/ranges.ts
CHANGED
|
@@ -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.
|