@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +97 -6
  3. package/dist/api/generated/schema.d.ts +8 -2
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  6. package/dist/default/FacetGroupControl.js +31 -4
  7. package/dist/default/FacetGroupControl.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts +24 -0
  9. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  10. package/dist/default/FacetPanelPane.js +27 -6
  11. package/dist/default/FacetPanelPane.js.map +1 -1
  12. package/dist/default/SearchPage.d.ts +33 -1
  13. package/dist/default/SearchPage.d.ts.map +1 -1
  14. package/dist/default/SearchPage.js +32 -5
  15. package/dist/default/SearchPage.js.map +1 -1
  16. package/dist/default/SearchResultsPane.d.ts +76 -0
  17. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  18. package/dist/default/SearchResultsPane.js +132 -45
  19. package/dist/default/SearchResultsPane.js.map +1 -1
  20. package/dist/default/index.d.ts +4 -2
  21. package/dist/default/index.d.ts.map +1 -1
  22. package/dist/default/index.js +2 -1
  23. package/dist/default/index.js.map +1 -1
  24. package/dist/default/swatches.d.ts +68 -0
  25. package/dist/default/swatches.d.ts.map +1 -0
  26. package/dist/default/swatches.js +258 -0
  27. package/dist/default/swatches.js.map +1 -0
  28. package/dist/state/facets.d.ts +17 -0
  29. package/dist/state/facets.d.ts.map +1 -1
  30. package/dist/state/facets.js +8 -0
  31. package/dist/state/facets.js.map +1 -1
  32. package/llms.txt +2 -2
  33. package/manifest.json +3 -1
  34. package/nav-manifest.json +1 -1
  35. package/package.json +5 -5
  36. package/src/analytics/generated/events.json +1 -1
  37. package/src/api/generated/schema.ts +8 -2
  38. package/src/default/FacetGroupControl.tsx +39 -0
  39. package/src/default/FacetPanelPane.tsx +61 -5
  40. package/src/default/SearchPage.tsx +72 -1
  41. package/src/default/SearchResultsPane.tsx +134 -5
  42. package/src/default/index.ts +15 -0
  43. package/src/default/swatches.ts +286 -0
  44. 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;
@@ -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,