@stapel/search-react 0.39.0 → 0.41.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 +167 -0
- package/README.md +97 -6
- package/dist/api/generated/schema.d.ts +8 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +31 -4
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +24 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -6
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +33 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +32 -5
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +76 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +132 -45
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +4 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +2 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/swatches.d.ts +68 -0
- package/dist/default/swatches.d.ts.map +1 -0
- package/dist/default/swatches.js +258 -0
- package/dist/default/swatches.js.map +1 -0
- package/dist/state/facets.d.ts +17 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +8 -0
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +3 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +8 -2
- package/src/default/FacetGroupControl.tsx +39 -0
- package/src/default/FacetPanelPane.tsx +61 -5
- package/src/default/SearchPage.tsx +72 -1
- package/src/default/SearchResultsPane.tsx +134 -5
- package/src/default/index.ts +15 -0
- package/src/default/swatches.ts +286 -0
- package/src/state/facets.ts +27 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A COLOUR FACET SHOWS THE COLOUR.
|
|
3
|
+
*
|
|
4
|
+
* The reference's colour group draws a filled dot beside every value; ours
|
|
5
|
+
* drew the word and made the buyer read it (deep/elektronika-telefony.md §3,
|
|
6
|
+
* the one rail regression that pass found). A colour is the one attribute
|
|
7
|
+
* whose label is strictly worse than the thing itself — "silver" versus
|
|
8
|
+
* "gold" in a catalogue's own transliteration is a paragraph of prose for a difference a 10px dot settles.
|
|
9
|
+
*
|
|
10
|
+
* Two questions, and each is answered conservatively, because both failures
|
|
11
|
+
* are visible on a shopper's screen:
|
|
12
|
+
*
|
|
13
|
+
* 1. **is this axis a colour?** — {@link isColorAxis}, from the slug the
|
|
14
|
+
* catalogue mapped the axis to (and the address key, and `axis_role` if a
|
|
15
|
+
* schema ever carries one). A guess on the slug, exactly like
|
|
16
|
+
* `looksLikeExclusiveAxisSlug` next door: nothing on the wire marks an
|
|
17
|
+
* axis "colour", and inspecting the VALUES for colour-ish names would put
|
|
18
|
+
* dots on a paint-brand axis whose makes are called `Bordeaux`;
|
|
19
|
+
* 2. **which colour is this value?** — {@link termHue} first, then
|
|
20
|
+
* {@link swatchColor}.
|
|
21
|
+
*
|
|
22
|
+
* The answer used to be "nobody said", and it was true of every source
|
|
23
|
+
* this pair could read: a value code is a catalogue's own term
|
|
24
|
+
* (`chernyy`, `dark-slate-2`) and neither the answer, the feature schema
|
|
25
|
+
* nor the vocabulary endpoint carried a hue for it. It is no longer true.
|
|
26
|
+
* The CATALOGUE knows — a vocabulary term carries the source's own bag —
|
|
27
|
+
* and since stapel-search 0.16.5 the answer ships it as
|
|
28
|
+
* `facet_labels[<slug>].extras[<code>]`, so `chernyy` arrives with
|
|
29
|
+
* `{hue: "#1a1a1a"}` beside its caption. That is read FIRST and it is the
|
|
30
|
+
* only arm that can ever be right about a transliteration.
|
|
31
|
+
*
|
|
32
|
+
* What follows it is unchanged and stays as the fallback, because most
|
|
33
|
+
* deployments send no bag at all (an older server, a resolver without the
|
|
34
|
+
* wider read, a level whose terms carry nothing): the design system's own
|
|
35
|
+
* colour roles (§68: one neutral vocabulary of ROLES, and it deliberately
|
|
36
|
+
* ships no hue ramp), then CSS's own colour keywords, then a code that
|
|
37
|
+
* spells the hue out in hex — and NOTHING otherwise. An invented mapping
|
|
38
|
+
* from a transliterated Russian word to a hex value is still data this
|
|
39
|
+
* pair does not have; what changed is that the catalogue can hand it over.
|
|
40
|
+
*/
|
|
41
|
+
import { cssVar } from "@stapel/tokens";
|
|
42
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
43
|
+
|
|
44
|
+
/** One vocabulary term's own bag, as the answer ships it: the source
|
|
45
|
+
* catalogue's keys, of which this module reads exactly one. */
|
|
46
|
+
export type TermExtra = Readonly<Record<string, unknown>>;
|
|
47
|
+
|
|
48
|
+
/** What {@link isColorAxis} needs of a group — the shape `FacetGroup` has. */
|
|
49
|
+
export interface ColorAxisLike {
|
|
50
|
+
readonly slug: string;
|
|
51
|
+
readonly urlKey?: string;
|
|
52
|
+
readonly feature?: FeatureDef | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* `facet_labels[<slug>].extras` — `{code: {…}}`, the vocabulary term's own
|
|
55
|
+
* bag for the codes that carry one (stapel-search 0.16.5+).
|
|
56
|
+
*
|
|
57
|
+
* OPTIONAL, and absent far more often than present: an older server, a
|
|
58
|
+
* deployment whose vocabulary resolver does not serve bags, and a level
|
|
59
|
+
* whose terms carry nothing all arrive the same way. Every read of it is
|
|
60
|
+
* optional-chained and falls through to {@link swatchColor}.
|
|
61
|
+
*/
|
|
62
|
+
readonly extras?: Readonly<Record<string, TermExtra>> | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The key a colour term's bag carries its hue under. One key, read by name:
|
|
66
|
+
* the rest of the bag is the source catalogue's and none of this pair's
|
|
67
|
+
* business. */
|
|
68
|
+
const HUE_KEY = "hue";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The control-type tails a catalogue hangs on an axis slug when one feature
|
|
72
|
+
* type is not enough to tell two mappings apart (`color_ref_select` is the
|
|
73
|
+
* live phones leaf's own colour axis; `color_select` and `color_multi` are
|
|
74
|
+
* the same axis under a different editor).
|
|
75
|
+
*
|
|
76
|
+
* Stripped before the head is read, so the control a value is PICKED with
|
|
77
|
+
* cannot change what the value IS.
|
|
78
|
+
*/
|
|
79
|
+
const CONTROL_TAILS: readonly string[] = [
|
|
80
|
+
"ref_hierarchical_select",
|
|
81
|
+
"hierarchical_select",
|
|
82
|
+
"ref_select",
|
|
83
|
+
"multiselect",
|
|
84
|
+
"multi_select",
|
|
85
|
+
"select",
|
|
86
|
+
"multi",
|
|
87
|
+
"picker",
|
|
88
|
+
"field",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
function normalizeSlug(slug: string): string {
|
|
92
|
+
let normalized = slug.toLowerCase().replace(/-/g, "_");
|
|
93
|
+
for (const tail of CONTROL_TAILS) {
|
|
94
|
+
if (normalized.endsWith(`_${tail}`)) {
|
|
95
|
+
normalized = normalized.slice(0, -(tail.length + 1));
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The two spellings, and only as the slug's HEAD segment: `color_fridge` is
|
|
103
|
+
* the colour of a fridge and `colorado_region` is a place. */
|
|
104
|
+
const COLOR_HEADS: ReadonlySet<string> = new Set(["color", "colour"]);
|
|
105
|
+
|
|
106
|
+
function headIsColor(slug: string | undefined): boolean {
|
|
107
|
+
if (slug === undefined || slug === "") return false;
|
|
108
|
+
const normalized = normalizeSlug(slug);
|
|
109
|
+
const head = normalized.split("_")[0] ?? "";
|
|
110
|
+
return COLOR_HEADS.has(head);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Is this axis a colour vocabulary?
|
|
115
|
+
*
|
|
116
|
+
* Three sources, in the order of how much authority they carry:
|
|
117
|
+
*
|
|
118
|
+
* - `axis_role` — the schema SAYING what an axis is, which is the only
|
|
119
|
+
* non-guess available. The canon's role vocabulary is closed and has no
|
|
120
|
+
* colour in it yet (`make`/`model`/`generation`/`year`/`mileage`), so this
|
|
121
|
+
* arm reads the field as text and is dead until the canon grows one. It is
|
|
122
|
+
* written now so that the day it does, nothing here has to change;
|
|
123
|
+
* - the axis slug, and the ADDRESS key beside it — the live phones leaf maps
|
|
124
|
+
* its colour to `color_ref_select` and publishes it as `color`, so either
|
|
125
|
+
* spelling alone would miss half the deployments.
|
|
126
|
+
*/
|
|
127
|
+
export function isColorAxis(group: ColorAxisLike): boolean {
|
|
128
|
+
const role = group.feature?.["axis_role"];
|
|
129
|
+
if (typeof role === "string" && COLOR_HEADS.has(role.toLowerCase())) return true;
|
|
130
|
+
if (headIsColor(group.slug) || headIsColor(group.urlKey)) return true;
|
|
131
|
+
// The catalogue SHOWING a hue, which outranks every guess above it — a term
|
|
132
|
+
// carrying `hue` is a colour whatever the axis is spelled. This is what
|
|
133
|
+
// reaches an axis the reference calls `tsvet` and publishes under a key
|
|
134
|
+
// neither spelling matches: the values say what the slug does not.
|
|
135
|
+
return hasAnyHue(group);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Does ANY value of this group carry a hue? */
|
|
139
|
+
function hasAnyHue(group: ColorAxisLike): boolean {
|
|
140
|
+
const extras = group.extras;
|
|
141
|
+
if (extras === undefined) return false;
|
|
142
|
+
for (const code of Object.keys(extras)) {
|
|
143
|
+
if (hueOf(extras[code]) !== null) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The hue one bag states, validated as a colour this pair would accept from
|
|
149
|
+
* anywhere else. A catalogue is a data source, not a stylesheet: a bag
|
|
150
|
+
* carrying `red herring` or a `javascript:` string must not reach a CSS
|
|
151
|
+
* property, so the same {@link swatchColor} vocabulary gates it. */
|
|
152
|
+
function hueOf(extra: TermExtra | undefined): string | null {
|
|
153
|
+
const stated = extra?.[HUE_KEY];
|
|
154
|
+
return typeof stated === "string" ? swatchColor(stated) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The hue the CATALOGUE states for one value, or `null` when it states none.
|
|
159
|
+
*
|
|
160
|
+
* The only source that can be right about a transliterated term, and the
|
|
161
|
+
* reason it exists: `chernyy` is not a CSS keyword and never will be, but the
|
|
162
|
+
* vocabulary level that defines it carries `{hue: "#1a1a1a"}` and the answer
|
|
163
|
+
* now ships that beside the caption.
|
|
164
|
+
*/
|
|
165
|
+
export function termHue(group: ColorAxisLike, code: string): string | null {
|
|
166
|
+
return hueOf(group.extras?.[code]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The design system's colour ROLES, for a value code that names one.
|
|
171
|
+
*
|
|
172
|
+
* The fleet's token vocabulary is neutral and role-shaped on purpose (§68):
|
|
173
|
+
* there is no `red`, no `blue`, and no ramp — so a colour axis whose values
|
|
174
|
+
* are hues matches nothing here, which is the correct answer rather than a
|
|
175
|
+
* gap. What does match is a catalogue that codes STATES as colours (a
|
|
176
|
+
* `status` axis mapped under a colour slug), and those take the brand's own
|
|
177
|
+
* value in both themes rather than a frozen hex.
|
|
178
|
+
*/
|
|
179
|
+
const TOKEN_ROLE_SWATCHES: Readonly<Record<string, string>> = {
|
|
180
|
+
brand: cssVar("brand"),
|
|
181
|
+
error: cssVar("error"),
|
|
182
|
+
info: cssVar("info"),
|
|
183
|
+
link: cssVar("link"),
|
|
184
|
+
success: cssVar("success"),
|
|
185
|
+
surface: cssVar("surface"),
|
|
186
|
+
text: cssVar("text"),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* CSS's own colour keywords — a NAME that matches, in the vocabulary every
|
|
191
|
+
* browser already agrees on.
|
|
192
|
+
*
|
|
193
|
+
* The basic sixteen plus the extended keywords a product catalogue actually
|
|
194
|
+
* uses. Deliberately not the full 148: every entry here is a promise that a
|
|
195
|
+
* value code spelled that way means that colour, and `rebeccapurple` in a
|
|
196
|
+
* phone catalogue is far likelier to be somebody's model name.
|
|
197
|
+
*/
|
|
198
|
+
const CSS_COLOR_KEYWORDS: readonly string[] = [
|
|
199
|
+
"aqua",
|
|
200
|
+
"beige",
|
|
201
|
+
"black",
|
|
202
|
+
"blue",
|
|
203
|
+
"brown",
|
|
204
|
+
"chocolate",
|
|
205
|
+
"coral",
|
|
206
|
+
"crimson",
|
|
207
|
+
"cyan",
|
|
208
|
+
"fuchsia",
|
|
209
|
+
"gold",
|
|
210
|
+
"gray",
|
|
211
|
+
"green",
|
|
212
|
+
"grey",
|
|
213
|
+
"indigo",
|
|
214
|
+
"ivory",
|
|
215
|
+
"khaki",
|
|
216
|
+
"lavender",
|
|
217
|
+
"lime",
|
|
218
|
+
"magenta",
|
|
219
|
+
"maroon",
|
|
220
|
+
"navy",
|
|
221
|
+
"olive",
|
|
222
|
+
"orange",
|
|
223
|
+
"orchid",
|
|
224
|
+
"pink",
|
|
225
|
+
"plum",
|
|
226
|
+
"purple",
|
|
227
|
+
"red",
|
|
228
|
+
"salmon",
|
|
229
|
+
"sand",
|
|
230
|
+
"sienna",
|
|
231
|
+
"silver",
|
|
232
|
+
"skyblue",
|
|
233
|
+
"tan",
|
|
234
|
+
"teal",
|
|
235
|
+
"tomato",
|
|
236
|
+
"turquoise",
|
|
237
|
+
"violet",
|
|
238
|
+
"wheat",
|
|
239
|
+
"white",
|
|
240
|
+
"yellow",
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
const CSS_COLOR_SET: ReadonlySet<string> = new Set(CSS_COLOR_KEYWORDS);
|
|
244
|
+
|
|
245
|
+
/** `#abc`, `#aabbcc`, `#aabbccdd` — a catalogue that codes the hue itself. */
|
|
246
|
+
const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The CSS colour one value code names, or `null` for "nobody said".
|
|
250
|
+
*
|
|
251
|
+
* `null` is the ordinary answer and the row then draws no dot at all: a grey
|
|
252
|
+
* placeholder beside eleven values would say "these are all the same colour",
|
|
253
|
+
* which is worse than the word on its own.
|
|
254
|
+
*
|
|
255
|
+
* The code is read as written apart from case and separators — `dark_blue`
|
|
256
|
+
* and `dark-blue` are one code and neither is a CSS keyword, so both get
|
|
257
|
+
* nothing. Only a code that IS a name resolves.
|
|
258
|
+
*/
|
|
259
|
+
export function swatchColor(code: string): string | null {
|
|
260
|
+
const raw = code.trim();
|
|
261
|
+
if (raw === "") return null;
|
|
262
|
+
if (HEX.test(raw)) return raw;
|
|
263
|
+
const normalized = raw.toLowerCase().replace(/[-_\s]/g, "");
|
|
264
|
+
const role = TOKEN_ROLE_SWATCHES[normalized];
|
|
265
|
+
if (role !== undefined) return role;
|
|
266
|
+
return CSS_COLOR_SET.has(normalized) ? normalized : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The dot a value gets when this axis is a colour AND something knows the
|
|
271
|
+
* value's hue — the catalogue's own bag first ({@link termHue}), then the
|
|
272
|
+
* value code read as a name ({@link swatchColor}). `null` everywhere else,
|
|
273
|
+
* which is still most of the time.
|
|
274
|
+
*/
|
|
275
|
+
export function facetSwatch(group: ColorAxisLike, code: string): string | null {
|
|
276
|
+
if (!isColorAxis(group)) return null;
|
|
277
|
+
// The catalogue's own answer first — it is the only one that can know what
|
|
278
|
+
// `chernyy` looks like. The name-resolving arms stay behind it for every
|
|
279
|
+
// deployment that ships no bag, and for a code the bag skipped.
|
|
280
|
+
return termHue(group, code) ?? swatchColor(code);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The swatch's own size, in CSS pixels: a dot beside a line of text, sized
|
|
284
|
+
* to the x-height rather than to the control, so it reads as part of the
|
|
285
|
+
* label and not as a second checkbox. */
|
|
286
|
+
export const SWATCH_SIZE = 12;
|
package/src/state/facets.ts
CHANGED
|
@@ -258,6 +258,23 @@ export interface FacetGroup {
|
|
|
258
258
|
/** Which source named the heading. `"none"` is the slug standing in for a
|
|
259
259
|
* name nobody has; a surface marks it so a storefront test can fail on it. */
|
|
260
260
|
readonly labelSource: FacetLabelSource;
|
|
261
|
+
/**
|
|
262
|
+
* `facet_labels[<slug>].extras` — `{code: {…}}`, what the vocabulary term
|
|
263
|
+
* behind a value carries BESIDES its caption, for the codes that carry
|
|
264
|
+
* anything (stapel-search 0.16.5+).
|
|
265
|
+
*
|
|
266
|
+
* The live reader of it is the colour swatch: a catalogue's colour code is
|
|
267
|
+
* its own transliteration (`chernyy`), which no client can turn into a
|
|
268
|
+
* hue, and the term has carried `{hue: "#1a1a1a"}` in the catalogue all
|
|
269
|
+
* along. Carried on the group for the same reason `urlKey` is — the panel,
|
|
270
|
+
* the chip row and the popular-values block all draw the same value and
|
|
271
|
+
* must not each re-derive what it looks like.
|
|
272
|
+
*
|
|
273
|
+
* `undefined` on an older server, on a deployment whose resolver serves no
|
|
274
|
+
* bags, and on a group whose counted codes carry none: the three are
|
|
275
|
+
* indistinguishable and none of them is actionable.
|
|
276
|
+
*/
|
|
277
|
+
readonly extras?: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined;
|
|
261
278
|
/** The category-schema entry behind the slug, when the host supplied one. */
|
|
262
279
|
readonly feature: FeatureDef | undefined;
|
|
263
280
|
/** `false` when the server skipped this slug — counts are `null`. */
|
|
@@ -632,6 +649,15 @@ function optionalOrder(order: number | null | undefined): { order?: number } {
|
|
|
632
649
|
return typeof order === "number" ? { order } : {};
|
|
633
650
|
}
|
|
634
651
|
|
|
652
|
+
/** The answer's term bags for one slug, when it sent any. An empty map is
|
|
653
|
+
* dropped with an absent one: neither says anything a surface can draw. */
|
|
654
|
+
function optionalExtras(
|
|
655
|
+
extras: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined
|
|
656
|
+
): { extras?: Readonly<Record<string, Readonly<Record<string, unknown>>>> } {
|
|
657
|
+
if (extras === undefined || Object.keys(extras).length === 0) return {};
|
|
658
|
+
return { extras };
|
|
659
|
+
}
|
|
660
|
+
|
|
635
661
|
function resolveVocabulary(
|
|
636
662
|
input: BuildFacetGroupsInput,
|
|
637
663
|
feature: FeatureDef | undefined,
|
|
@@ -840,6 +866,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
840
866
|
slug,
|
|
841
867
|
urlKey: keys.write[slug] ?? slug,
|
|
842
868
|
...optionalOrder(input.facetLabels?.[slug]?.order),
|
|
869
|
+
...optionalExtras(input.facetLabels?.[slug]?.extras),
|
|
843
870
|
...optionalVocabulary(resolveVocabulary(input, feature, slug)),
|
|
844
871
|
...resolveGroupLabel(input, feature, slug),
|
|
845
872
|
feature,
|