@stapel/search-react 0.10.0 → 0.12.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 (130) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/MODULE.md +111 -0
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +76 -6
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/searchApi.d.ts +13 -6
  7. package/dist/api/searchApi.d.ts.map +1 -1
  8. package/dist/api/searchApi.js.map +1 -1
  9. package/dist/api/types.d.ts +107 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js +29 -0
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/DegradationNotice.d.ts +22 -13
  14. package/dist/default/DegradationNotice.d.ts.map +1 -1
  15. package/dist/default/DegradationNotice.js +7 -8
  16. package/dist/default/DegradationNotice.js.map +1 -1
  17. package/dist/default/FacetPanelPane.d.ts +13 -2
  18. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  19. package/dist/default/FacetPanelPane.js +29 -16
  20. package/dist/default/FacetPanelPane.js.map +1 -1
  21. package/dist/default/FilterChips.d.ts +88 -3
  22. package/dist/default/FilterChips.d.ts.map +1 -1
  23. package/dist/default/FilterChips.js +166 -13
  24. package/dist/default/FilterChips.js.map +1 -1
  25. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  26. package/dist/default/LocationSummaryLine.js +1 -1
  27. package/dist/default/LocationSummaryLine.js.map +1 -1
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  29. package/dist/default/RangeFilterRow.js +49 -5
  30. package/dist/default/RangeFilterRow.js.map +1 -1
  31. package/dist/default/SearchBox.d.ts.map +1 -1
  32. package/dist/default/SearchBox.js +74 -4
  33. package/dist/default/SearchBox.js.map +1 -1
  34. package/dist/default/SearchPage.d.ts +29 -1
  35. package/dist/default/SearchPage.d.ts.map +1 -1
  36. package/dist/default/SearchPage.js +25 -10
  37. package/dist/default/SearchPage.js.map +1 -1
  38. package/dist/default/SearchResultsPane.d.ts +22 -0
  39. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  40. package/dist/default/SearchResultsPane.js +2 -2
  41. package/dist/default/SearchResultsPane.js.map +1 -1
  42. package/dist/default/SortSelect.d.ts +23 -0
  43. package/dist/default/SortSelect.d.ts.map +1 -1
  44. package/dist/default/SortSelect.js +29 -9
  45. package/dist/default/SortSelect.js.map +1 -1
  46. package/dist/default/index.d.ts +2 -2
  47. package/dist/default/index.d.ts.map +1 -1
  48. package/dist/default/index.js +1 -1
  49. package/dist/default/index.js.map +1 -1
  50. package/dist/headless/FacetPanel.d.ts +26 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +16 -1
  53. package/dist/headless/FacetPanel.js.map +1 -1
  54. package/dist/headless/useFacetLabels.d.ts +39 -0
  55. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  56. package/dist/headless/useFacetLabels.js +129 -0
  57. package/dist/headless/useFacetLabels.js.map +1 -0
  58. package/dist/headless/useSearchBox.d.ts +71 -0
  59. package/dist/headless/useSearchBox.d.ts.map +1 -1
  60. package/dist/headless/useSearchBox.js +67 -2
  61. package/dist/headless/useSearchBox.js.map +1 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +8 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +48 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +58 -0
  68. package/dist/i18n/keys.js.map +1 -1
  69. package/dist/i18n/ru.d.ts.map +1 -1
  70. package/dist/i18n/ru.js +10 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +9 -7
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +6 -5
  75. package/dist/index.js.map +1 -1
  76. package/dist/model/queries.d.ts +6 -5
  77. package/dist/model/queries.d.ts.map +1 -1
  78. package/dist/model/queries.js +4 -3
  79. package/dist/model/queries.js.map +1 -1
  80. package/dist/model/queryKeys.d.ts +12 -0
  81. package/dist/model/queryKeys.d.ts.map +1 -1
  82. package/dist/model/queryKeys.js +7 -0
  83. package/dist/model/queryKeys.js.map +1 -1
  84. package/dist/nav/manifest.d.ts +21 -0
  85. package/dist/nav/manifest.d.ts.map +1 -1
  86. package/dist/nav/manifest.js +3 -2
  87. package/dist/nav/manifest.js.map +1 -1
  88. package/dist/state/degradations.d.ts +32 -0
  89. package/dist/state/degradations.d.ts.map +1 -1
  90. package/dist/state/degradations.js +33 -0
  91. package/dist/state/degradations.js.map +1 -1
  92. package/dist/state/facets.d.ts +64 -0
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +134 -9
  95. package/dist/state/facets.js.map +1 -1
  96. package/dist/state/ranges.d.ts +45 -3
  97. package/dist/state/ranges.d.ts.map +1 -1
  98. package/dist/state/ranges.js +55 -14
  99. package/dist/state/ranges.js.map +1 -1
  100. package/llms.txt +2 -2
  101. package/manifest.json +27 -2
  102. package/nav-manifest.json +4 -3
  103. package/package.json +10 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/api/generated/schema.ts +76 -6
  106. package/src/api/searchApi.ts +13 -6
  107. package/src/api/types.ts +115 -1
  108. package/src/default/DegradationNotice.tsx +30 -20
  109. package/src/default/FacetPanelPane.tsx +55 -25
  110. package/src/default/FilterChips.tsx +290 -38
  111. package/src/default/LocationSummaryLine.tsx +6 -1
  112. package/src/default/RangeFilterRow.tsx +61 -3
  113. package/src/default/SearchBox.tsx +134 -6
  114. package/src/default/SearchPage.tsx +79 -5
  115. package/src/default/SearchResultsPane.tsx +48 -11
  116. package/src/default/SortSelect.tsx +70 -9
  117. package/src/default/index.ts +10 -2
  118. package/src/headless/FacetPanel.tsx +44 -1
  119. package/src/headless/useFacetLabels.ts +172 -0
  120. package/src/headless/useSearchBox.ts +132 -5
  121. package/src/i18n/es.ts +9 -0
  122. package/src/i18n/keys.ts +60 -0
  123. package/src/i18n/ru.ts +11 -0
  124. package/src/index.ts +28 -4
  125. package/src/model/queries.ts +6 -5
  126. package/src/model/queryKeys.ts +23 -0
  127. package/src/nav/manifest.ts +24 -2
  128. package/src/state/degradations.ts +56 -0
  129. package/src/state/facets.ts +181 -9
  130. package/src/state/ranges.ts +85 -15
@@ -32,6 +32,22 @@ export const searchQueryKeys: {
32
32
  query(
33
33
  params: SearchQueryKeyParams
34
34
  ): readonly ["search", "query", SearchQueryKeyParams];
35
+ /**
36
+ * One group's captions from the HOST's resolver — the seam that names a
37
+ * vocabulary-backed facet value on a server too old to caption it itself.
38
+ *
39
+ * Keyed on the slug, the values asked about and the locale. The values are
40
+ * SORTED by the caller before they get here: the same group arrives in
41
+ * count order, and count order changes on every click, so an unsorted key
42
+ * would re-ask the host for the same words each time a sibling facet moved.
43
+ * The locale is in the key because a caption is copy — one cache entry per
44
+ * language, never one shared between two.
45
+ */
46
+ facetLabels(
47
+ slug: string,
48
+ values: readonly string[],
49
+ locale?: string
50
+ ): readonly ["search", "facet-labels", string, readonly string[], string | null];
35
51
  /** The P2B disclosure. `type` is optional on the endpoint; normalized to
36
52
  * `null` so an absent type and an explicit `undefined` cannot cache twice. */
37
53
  ranking(type?: string): readonly ["search", "ranking", string | null];
@@ -43,6 +59,13 @@ export const searchQueryKeys: {
43
59
  } = {
44
60
  all: [ROOT],
45
61
  query: (params) => [ROOT, "query", params],
62
+ facetLabels: (slug, values, locale) => [
63
+ ROOT,
64
+ "facet-labels",
65
+ slug,
66
+ values,
67
+ locale ?? null,
68
+ ],
46
69
  ranking: (type) => [ROOT, "ranking", type ?? null],
47
70
  suggest: (type, q, limit) => [ROOT, "suggest", type, q, limit ?? null],
48
71
  };
@@ -15,13 +15,34 @@
15
15
  * `menuVisibleDefault: false` on both: the results page is a navigation TARGET
16
16
  * reached from the header's search box, and the ranking disclosure is a
17
17
  * footer link. Neither is a menu item — the same treatment `auth.login` gets.
18
+ *
19
+ * ── A nav label is not a page heading ─────────────────────────────────────
20
+ *
21
+ * Both entries carry their OWN label keys (`search.nav.*`) rather than
22
+ * borrowing the surfaces' captions. `search.results.title` is the heading over
23
+ * a list of matches, so it reads "Results" — correct above rows, and useless
24
+ * in a menu, where "Results" answers nothing about where the link goes. The
25
+ * destination is the search. `search.ranking.title` is worse in the other
26
+ * direction: it is a whole sentence ("How these results are ordered"), which
27
+ * is right on the page it captions and cannot be a menu row.
28
+ *
29
+ * Sharing one key would also make the two uses move together forever: a
30
+ * translator improving the heading would silently rewrite the menu, and there
31
+ * is no locale in which the best heading and the best menu label are reliably
32
+ * the same words.
33
+ *
34
+ * `shortLabelKey` on the disclosure for the same reason one step smaller: a
35
+ * five-cell phone dock gives a destination roughly ten characters, and
36
+ * "Ranking disclosure" ellipsizes there to a fragment a person has to guess
37
+ * at. `search.results` declares none — its label is already one word in every
38
+ * locale this pair ships.
18
39
  */
19
40
  import type { NavEntry } from "@stapel/core";
20
41
 
21
42
  export const navEntries: readonly NavEntry[] = [
22
43
  {
23
44
  id: "search.results",
24
- labelKey: "search.results.title",
45
+ labelKey: "search.nav.results",
25
46
  icon: "SearchOutlined",
26
47
  route: { path: "/s" },
27
48
  component: { export: "SearchPage", subpath: "default" },
@@ -40,7 +61,8 @@ export const navEntries: readonly NavEntry[] = [
40
61
  // be mistaken — by a person or by `stapel/no-string-paths` — for the API
41
62
  // operation of the same name.
42
63
  id: "search.ranking",
43
- labelKey: "search.ranking.title",
64
+ labelKey: "search.nav.ranking",
65
+ shortLabelKey: "search.nav.ranking.short",
44
66
  icon: "OrderedListOutlined",
45
67
  route: { path: "/ranking-disclosure" },
46
68
  component: { export: "RankingDisclosurePane", subpath: "default" },
@@ -133,3 +133,59 @@ export function isCountNuanceOnly(
133
133
  degradations.every((degradation) => degradation.kind === "exact_total")
134
134
  );
135
135
  }
136
+
137
+ /**
138
+ * Who a degradation is addressed to.
139
+ *
140
+ * - `"reader"` — it changes what THIS PAGE MEANS. "Counts are approximate",
141
+ * "subcategories may be missing", "a ranking parameter is inactive", and
142
+ * anything this build has no wording for. A buyer can act on these: read
143
+ * the numbers as estimates, widen the category, distrust the order.
144
+ * - `"operator"` — it describes the ENGINE THIS DEPLOYMENT CHOSE. Nothing a
145
+ * buyer does changes it, and it is the same sentence on every query
146
+ * forever, which is precisely what makes it invisible by the time it
147
+ * matters.
148
+ */
149
+ export type SearchDegradationAudience = "reader" | "operator";
150
+
151
+ const OPERATOR_KINDS: ReadonlySet<string> = new Set<SearchDegradationKind>([
152
+ // Both of these say, in the shipped copy, "the search engine in use
153
+ // cannot do this" — a sentence about a procurement decision, printed at a
154
+ // person trying to buy a phone.
155
+ "typo_tolerance",
156
+ "phrase_synonyms",
157
+ // A count nuance the count itself already speaks, as "N+". This is the
158
+ // rule `isCountNuanceOnly` encoded for one kind, generalized.
159
+ "exact_total",
160
+ ]);
161
+
162
+ /** {@link SearchDegradationAudience} for one kind. */
163
+ export function degradationAudience(
164
+ kind: SearchDegradationKind
165
+ ): SearchDegradationAudience {
166
+ return OPERATOR_KINDS.has(kind) ? "operator" : "reader";
167
+ }
168
+
169
+ /**
170
+ * The degradations a buyer should be told about.
171
+ *
172
+ * The live defect this exists for: a classified board raised a full-screen
173
+ * yellow "What this search could not do: synonyms were not substituted —
174
+ * the search engine in use cannot do this" between the sort control and
175
+ * the first card, on every query, for every buyer. It was not a lie about a
176
+ * broken thing — stapel-search really did report `phrase_synonyms` on every
177
+ * query with text — which is exactly why deleting the STRING would have been
178
+ * the wrong fix: the next engine-capability literal would have grown its own
179
+ * copy of it. What is wrong is the AUDIENCE, so the audience is the thing
180
+ * that got a name.
181
+ *
182
+ * `variant="debug"` on `<DegradationNotice>` shows the operator's half; a
183
+ * host that wants it on a status page renders that.
184
+ */
185
+ export function readerFacing(
186
+ degradations: readonly SearchDegradation[]
187
+ ): readonly SearchDegradation[] {
188
+ return degradations.filter(
189
+ (degradation) => degradationAudience(degradation.kind) === "reader"
190
+ );
191
+ }
@@ -8,22 +8,95 @@
8
8
  * they would have if you swapped to them. A panel that greys the siblings
9
9
  * out has silently converted a drill-down facet into a naive one, and the
10
10
  * e2e leg in the spec (§7.2) exists to catch exactly that.
11
- * 2. **The server does not send option LABELS** (`facets.py` returns
12
- * `{value: count}` and nothing else). The labels are in the category's
13
- * feature schema, which is why {@link buildFacetGroups} takes
14
- * `categoryFeatures` — the second slot-seam of the pair, filled by the
15
- * container from `categories-react` (spec §6.2 item 2). Without it the
16
- * panel still works and shows raw values; it does not invent labels.
11
+ * 2. **Option LABELS have one stated order: ANSWER, then schema, then the
12
+ * raw value** with a host resolver between the last two, applied by
13
+ * `useHostFacetLabels` on what this module could not name.
14
+ *
15
+ * The answer leads because it is the only source that always exists and
16
+ * the only one that has RESOLVED anything: `categoryFeatures` is an
17
+ * optional slot a live classified board never filled, and even where it is
18
+ * filled a `ref_select` config carries a pointer to a vocabulary and no
19
+ * option table at all — so the schema cannot name `apple` or `chernyy` no
20
+ * matter who threads it through. `facet_labels` (stapel-search 0.4.0+,
21
+ * vocabulary-backed from 0.6.0) is the server reading that vocabulary
22
+ * against the snapshot the documents were written with, and answering in
23
+ * the request's own language. The schema is the floor under it, for a
24
+ * server too old to send captions and for a slug the answer omits.
25
+ *
26
+ * Neither invents a label: a value nobody names renders as itself. A page
27
+ * against a pre-0.4.0 server therefore behaves exactly as it did — the key
28
+ * is absent, every option falls through to the schema, and nothing
29
+ * crashes on the missing key.
17
30
  *
18
31
  * A slug the server SKIPPED (`facet_meta.skipped`, dropped at
19
32
  * `MAX_FACET_FIELDS`) is not counted at all. Its options carry `count: null`,
20
33
  * never `0` — "we did not count this" and "there are none" are different
21
34
  * sentences and the honest one has to survive to the screen.
35
+ *
36
+ * 3. **Not every counted slug is a FILTER.** The plan is built from the leaf
37
+ * category's feature defs and the counter counts whatever is indexed, so a
38
+ * live classified deployment answers with `imei: {"355971829187494": 1}`
39
+ * and `video_file_url: {}` beside its brand and its condition. Neither is
40
+ * something a person narrows by — one is unique per document, the other is
41
+ * a URL — and on a 390px chip row a chip offering one IMEI with a count of
42
+ * one pushes the chips that DO narrow off the screen. Which slugs can be
43
+ * chipped is therefore decided from the category's own feature defs, by
44
+ * value TYPE (see {@link FACETABLE_FEATURE_TYPES}), not by what came back.
22
45
  */
23
- import { featureConfig, featureName, featureType, formatFeatureValue } from "@stapel/attributes-react";
46
+ import {
47
+ VOCABULARY_BACKED_TYPES,
48
+ featureConfig,
49
+ featureName,
50
+ featureType,
51
+ formatFeatureValue,
52
+ } from "@stapel/attributes-react";
24
53
  import type { FeatureDef } from "@stapel/attributes-react";
25
54
  import type { FacetMeta, SearchQueryState } from "../api/types.js";
26
55
 
56
+ /**
57
+ * Value types whose values are a BOUNDED OPTION SET — the only kind of
58
+ * feature a person can be offered as a filter.
59
+ *
60
+ * The list is not invented here: the ref/vocabulary-backed half is
61
+ * `@stapel/attributes-react`'s own `VOCABULARY_BACKED_TYPES` (the one place
62
+ * that knows which types are drawn from a vocabulary), and the rest are the
63
+ * select family plus `bool`. Everything else a category can declare — a
64
+ * `string`, an `int`, a `date`, a `hex_color`, a `group` — enumerates as many
65
+ * terms as there are documents, which is a list, not a choice.
66
+ *
67
+ * `int`/`float`/`convertible_unit` are absent here and present in
68
+ * `RANGE_FEATURE_TYPES` (`state/ranges.ts`): a number is narrowed with two
69
+ * bounds, not with a checkbox per value. They are not dropped from the panel,
70
+ * they are drawn by the other half of the model.
71
+ */
72
+ export const FACETABLE_FEATURE_TYPES: readonly string[] = [
73
+ "bool",
74
+ "hierarchical_select",
75
+ ...VOCABULARY_BACKED_TYPES,
76
+ "select",
77
+ ];
78
+
79
+ /**
80
+ * Can a person filter by the slug this feature def describes?
81
+ *
82
+ * **A missing feature def is not a "no".** `categoryFeatures` is an OPTIONAL
83
+ * slot and a whole deployment can run without it; a feature can also be
84
+ * retired between the write that indexed a value and the read that counts it.
85
+ * Answering "not facetable" for an absent def would blank the entire chip row
86
+ * for every host that never threaded the schema through — the same empty row
87
+ * this rule exists to fix, arrived at from the other side. So the schema can
88
+ * only ever REMOVE a chip it names and disowns; silence removes nothing.
89
+ *
90
+ * The same reasoning covers a def with no `config.type` at all: an untyped
91
+ * feature is a def that says nothing, and nothing is not a verdict.
92
+ */
93
+ export function isFacetableFeature(feature: FeatureDef | undefined): boolean {
94
+ if (feature === undefined) return true;
95
+ const type = featureType(feature);
96
+ if (type === undefined) return true;
97
+ return FACETABLE_FEATURE_TYPES.includes(type);
98
+ }
99
+
27
100
  /** One value of one facet. */
28
101
  export interface FacetOption {
29
102
  readonly value: string;
@@ -59,6 +132,33 @@ export interface BuildFacetGroupsInput {
59
132
  readonly state: SearchQueryState;
60
133
  /** The category's feature schema, for labels and option order. */
61
134
  readonly categoryFeatures?: readonly FeatureDef[];
135
+ /**
136
+ * The envelope's `facet_labels` (stapel-search 0.4.0+, vocabulary-backed
137
+ * from 0.6.0): `{slug: {translatable, values: {value: caption}}}`.
138
+ *
139
+ * The PRIMARY caption source, above `categoryFeatures`. Two reasons, and
140
+ * the second is the one that cannot be worked around:
141
+ *
142
+ * - it is always there. The schema slot is optional and a live classified
143
+ * board never filled it, so its buyers read "Condition: **b-u**" and
144
+ * "Listing kind: **prodayu-svoe**" on the SERP and in the filter chips.
145
+ * - it is the only source that RESOLVED anything. A `ref_select`'s config
146
+ * carries an `optionsRef` pointer and no option table, so a host that
147
+ * threads the entire schema through still has nothing to print for
148
+ * `apple` or `chernyy`. The server read that vocabulary against the
149
+ * snapshot the documents were written with.
150
+ *
151
+ * ABSENT on a server older than 0.4.0 — absent, not empty — which is why
152
+ * every read of it here is optional-chained and every option falls through
153
+ * to the schema and then to its raw self. No labels are invented at any
154
+ * step.
155
+ */
156
+ readonly facetLabels?: Readonly<
157
+ Record<
158
+ string,
159
+ { readonly translatable: boolean; readonly values: Readonly<Record<string, string>> }
160
+ >
161
+ >;
62
162
  /** Translator for label keys. */
63
163
  readonly t?: (key: string) => string;
64
164
  /** BCP-47 tag, forwarded to `formatFeatureValue` for `date` options. */
@@ -71,6 +171,26 @@ function translate(t: ((key: string) => string) | undefined, key: string): strin
71
171
  return resolved.length > 0 ? resolved : key;
72
172
  }
73
173
 
174
+ /**
175
+ * The caption the ANSWER carries for one option, or `undefined`.
176
+ *
177
+ * `translatable` is the server saying whether `values` holds translation
178
+ * KEYS or literal captions, and it has to be said rather than sniffed:
179
+ * `b.apple` and a rendered caption are both strings, and guessing wrong prints either a
180
+ * dotted key or an untranslated word at a buyer.
181
+ */
182
+ function serverLabel(
183
+ labels: BuildFacetGroupsInput["facetLabels"],
184
+ slug: string,
185
+ value: string,
186
+ t: ((key: string) => string) | undefined
187
+ ): string | undefined {
188
+ const entry = labels?.[slug];
189
+ const caption = entry?.values[value];
190
+ if (caption === undefined || caption.length === 0) return undefined;
191
+ return entry?.translatable === true ? translate(t, caption) : caption;
192
+ }
193
+
74
194
  /**
75
195
  * The declared option ORDER for a closed set, if the schema declares one.
76
196
  *
@@ -95,6 +215,34 @@ function declaredOptionValues(feature: FeatureDef | undefined): readonly string[
95
215
  return out;
96
216
  }
97
217
 
218
+ /**
219
+ * One option's caption: the ANSWER, then the schema, then the raw value.
220
+ *
221
+ * The answer leads because it is the source that always exists and the only
222
+ * one that can name a vocabulary-backed value at all: a `ref_select`'s config
223
+ * is a POINTER (`optionsRef`), so a host who threaded the whole schema through
224
+ * still has nothing to print for `apple`. The server read that vocabulary
225
+ * against the snapshot the documents carry and answered in the request's own
226
+ * language, which is a strictly better-informed caption than the option table
227
+ * the schema may or may not hold.
228
+ *
229
+ * The schema is the floor: a server too old to send `facet_labels`, a slug the
230
+ * answer omits, a value the vocabulary has since dropped. Below both, the raw
231
+ * value — never a blank, and never a guess.
232
+ */
233
+ function resolveLabel(
234
+ input: BuildFacetGroupsInput,
235
+ feature: FeatureDef | undefined,
236
+ slug: string,
237
+ value: string,
238
+ labelOptions: { t?: (key: string) => string; locale?: string }
239
+ ): string {
240
+ return (
241
+ serverLabel(input.facetLabels, slug, value, input.t) ??
242
+ facetOptionLabel(feature, value, labelOptions)
243
+ );
244
+ }
245
+
98
246
  /**
99
247
  * A facet value as a person reads it, through
100
248
  * `@stapel/attributes-react`'s `formatFeatureValue` — the same formatter the
@@ -156,6 +304,13 @@ export function facetOptionLabel(
156
304
  * person has filtered on — the last one matters: a filter whose slug fell out
157
305
  * of the plan must stay visible, or it becomes a constraint with no control to
158
306
  * remove it.
307
+ *
308
+ * A slug the category schema names and types as something no one can choose
309
+ * from (see {@link isFacetableFeature}) produces no group at all — not an
310
+ * empty one, because an empty group is still a heading in the panel and still
311
+ * a chip in the row. The APPLIED-filter clause outranks the type rule: a
312
+ * constraint the URL carries always gets its control back, whatever the schema
313
+ * now says about it, or a person is left holding a filter they cannot clear.
159
314
  */
160
315
  export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGroup[] {
161
316
  const bySlug = new Map<string, FeatureDef>();
@@ -171,6 +326,10 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
171
326
  ]) {
172
327
  if (seen.has(slug)) continue;
173
328
  seen.add(slug);
329
+ // Applied first, type second — in that order, so an `imei` somebody
330
+ // somehow got into a link keeps the control that removes it.
331
+ const applied = (input.state.filters[slug] ?? []).length > 0;
332
+ if (!applied && !isFacetableFeature(bySlug.get(slug))) continue;
174
333
  slugs.push(slug);
175
334
  }
176
335
 
@@ -180,7 +339,15 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
180
339
  const counted = !skipped.has(slug) && slug in input.facets;
181
340
  const selected = input.state.filters[slug] ?? [];
182
341
 
183
- const declared = declaredOptionValues(feature);
342
+ // The authored option order, from whichever copy of the category config
343
+ // this page has. An authored list reshuffled by count is a size chart
344
+ // that moves on every click, and until 0.11 a host that passed no schema
345
+ // got exactly that.
346
+ const fromSchema = declaredOptionValues(feature);
347
+ const declared =
348
+ fromSchema.length > 0
349
+ ? fromSchema
350
+ : Object.keys(input.facetLabels?.[slug]?.values ?? {});
184
351
  const values: string[] = [];
185
352
  const push = (value: string): void => {
186
353
  if (!values.includes(value)) values.push(value);
@@ -211,7 +378,12 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
211
378
  options: values.map((value) => ({
212
379
  value,
213
380
  count: counted ? (counts[value] ?? 0) : null,
214
- label: facetOptionLabel(feature, value, labelOptions),
381
+ // The host's schema first when it actually resolves the value, the
382
+ // answer's caption when it does not (or when there is no schema at
383
+ // all), the raw value when neither knows. `facetOptionLabel` returns
384
+ // the value unchanged for an option it cannot name, which is what
385
+ // makes "did it resolve?" answerable without a second lookup.
386
+ label: resolveLabel(input, feature, slug, value, labelOptions),
215
387
  selected: selected.includes(value),
216
388
  })),
217
389
  };
@@ -6,13 +6,23 @@
6
6
  * the panel only knew how to draw checkboxes. This module is the missing half:
7
7
  * which slugs a range row exists for, and what the row is called.
8
8
  *
9
- * ── Where a range row comes from, and why it is not the response ───────────
9
+ * ── Where a range row comes from ──────────────────────────────────────────
10
10
  *
11
- * A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE values;
12
- * a range is not enumerable and the server never sends one. So the rows come
13
- * from the CATEGORY SCHEMA the same `categoryFeatures` slot that gives the
14
- * checkboxes their labels — filtered to the numeric value types, plus every
15
- * slug the URL already carries a range for.
11
+ * A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE
12
+ * values; a range is not enumerable and no bucket is ever sent for one. So
13
+ * the rows come from two places:
14
+ *
15
+ * - the CATEGORY SCHEMA — the same `categoryFeatures` slot that gives the
16
+ * checkboxes their labels — filtered to the numeric value types;
17
+ * - the ANSWER's `facet_meta.core_ranges` (stapel-search 0.4.0+), which
18
+ * names the range slugs addressing a COLUMN of the document rather than
19
+ * an attribute. `price` is the shipped one, and it is why this module
20
+ * exists at all: a live classified board offered seven numeric ranges,
21
+ * every one of them a shipping or wholesale input, and no price — because
22
+ * price is not a category feature anywhere, and a row was only ever drawn
23
+ * for a feature.
24
+ *
25
+ * Plus every slug the URL already carries a range for.
16
26
  *
17
27
  * That last clause is the same rule `buildFacetGroups` follows for a filtered
18
28
  * slug that fell out of the plan: a constraint that is ACTIVE must always have
@@ -59,11 +69,50 @@ export interface RangeGroup {
59
69
  readonly step: number | undefined;
60
70
  /** Whether the URL currently constrains this slug. */
61
71
  readonly active: boolean;
72
+ /**
73
+ * `true` when the axis is a CORE document column the server declared in
74
+ * `facet_meta.core_ranges` rather than a category feature. Price is the
75
+ * shipped one. A core axis is drawn first and is never absent because a
76
+ * category forgot to declare an attribute for it.
77
+ */
78
+ readonly core: boolean;
79
+ /**
80
+ * ISO 4217 code when the row is money, so the control can read as money
81
+ * instead of as a bare integer. Only ever set on a core axis: an attribute
82
+ * carries a `postfix`, not a currency.
83
+ */
84
+ readonly currency: string | undefined;
85
+ }
86
+
87
+ /**
88
+ * i18n key for a core axis's own name.
89
+ *
90
+ * A core axis has no FeatureDef and therefore no `name` to translate. The
91
+ * label is this package's, because the axis is this package's.
92
+ */
93
+ export function coreRangeLabelKey(slug: string): string {
94
+ return `search.range.${slug}`;
62
95
  }
63
96
 
64
97
  export interface BuildRangeGroupsInput {
65
98
  readonly state: SearchQueryState;
66
99
  readonly categoryFeatures?: readonly FeatureDef[];
100
+ /**
101
+ * `facet_meta.core_ranges` — range slugs that address a column of the
102
+ * document rather than an attribute (stapel-search 0.4.0+).
103
+ *
104
+ * It comes from the ANSWER on purpose. A live classified board offered
105
+ * seven numeric ranges — parcel weight, length, height, width, packing
106
+ * quantity, minimum-order quantity, battery condition — and no price,
107
+ * because a row was only ever drawn for a category feature and price is a
108
+ * column of the listing. Hardcoding `"price"` here would have fixed that
109
+ * board and broken the next one, where `r.price` still answers zero
110
+ * because the server predates the axis. So the server says which axes it
111
+ * can actually serve, and this list is empty against an older one.
112
+ */
113
+ readonly coreRanges?: readonly string[];
114
+ /** ISO 4217 code for the money axes, when the surface knows one. */
115
+ readonly currency?: string;
67
116
  /** Translator for label keys (the schema's `name` is often one). */
68
117
  readonly t?: (key: string) => string;
69
118
  }
@@ -89,9 +138,15 @@ export function isRangeFeature(feature: FeatureDef): boolean {
89
138
  }
90
139
 
91
140
  /**
92
- * The range rows for the current search: every numeric feature of the
93
- * category, in the schema's own order, plus any slug the URL constrains that
94
- * the schema does not explain.
141
+ * The range rows for the current search, in the order they should be read:
142
+ * the CORE axes the answer declares, then every numeric feature of the
143
+ * category in the schema's own order, then any slug the URL constrains that
144
+ * neither explains.
145
+ *
146
+ * Core first is not cosmetic. On the board this was measured against, the
147
+ * seven numeric attributes a phone category happens to declare are all
148
+ * shipping and wholesale inputs; the one number a phone buyer narrows by is
149
+ * the price, and it belongs above them.
95
150
  */
96
151
  export function buildRangeGroups(
97
152
  input: BuildRangeGroupsInput
@@ -99,30 +154,45 @@ export function buildRangeGroups(
99
154
  const bySlug = new Map<string, FeatureDef>();
100
155
  for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
101
156
 
102
- const slugs: string[] = [];
157
+ const core = new Set(input.coreRanges ?? []);
158
+ const slugs: string[] = [...core];
103
159
  for (const feature of input.categoryFeatures ?? []) {
104
- if (isRangeFeature(feature)) slugs.push(feature.slug);
160
+ // A core slug shadows a same-named attribute — which is exactly what the
161
+ // server does with it (`index_schema.CORE_RANGE_FIELDS` reserves the
162
+ // slug), so drawing both would put two controls over one filter.
163
+ if (isRangeFeature(feature) && !core.has(feature.slug)) slugs.push(feature.slug);
105
164
  }
106
165
  for (const slug of Object.keys(input.state.ranges)) {
107
166
  if (!slugs.includes(slug)) slugs.push(slug);
108
167
  }
109
168
 
110
169
  return slugs.map((slug) => {
111
- const feature = bySlug.get(slug);
170
+ const isCore = core.has(slug);
171
+ const feature = isCore ? undefined : bySlug.get(slug);
112
172
  const config = feature === undefined ? {} : featureConfig(feature);
113
173
  const applied: SearchRange | undefined = input.state.ranges[slug];
114
174
  return {
115
175
  slug,
116
- label:
117
- feature === undefined ? slug : translate(input.t, featureName(feature)),
176
+ label: isCore
177
+ ? translate(input.t, coreRangeLabelKey(slug))
178
+ : feature === undefined
179
+ ? slug
180
+ : translate(input.t, featureName(feature)),
118
181
  feature,
119
182
  from: applied?.from,
120
183
  to: applied?.to,
121
184
  min: num(config["min"]),
122
185
  max: num(config["max"]),
123
- unit: str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"]),
186
+ // A core money axis carries a CURRENCY, not a unit suffix: "" is
187
+ // formatted from the code for the reader's locale, a unit suffix is a literal
188
+ // the category author typed.
189
+ unit: isCore
190
+ ? undefined
191
+ : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
124
192
  step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
125
193
  active: applied !== undefined,
194
+ core: isCore,
195
+ currency: isCore ? str(input.currency) : undefined,
126
196
  };
127
197
  });
128
198
  }