@stapel/search-react 0.11.0 → 0.13.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 (125) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/MODULE.md +118 -3
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +60 -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/FacetPanelPane.d.ts +13 -2
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +3 -1
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/FilterChips.d.ts +88 -3
  18. package/dist/default/FilterChips.d.ts.map +1 -1
  19. package/dist/default/FilterChips.js +161 -13
  20. package/dist/default/FilterChips.js.map +1 -1
  21. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  22. package/dist/default/LocationSummaryLine.js +1 -1
  23. package/dist/default/LocationSummaryLine.js.map +1 -1
  24. package/dist/default/SearchBox.d.ts.map +1 -1
  25. package/dist/default/SearchBox.js +74 -4
  26. package/dist/default/SearchBox.js.map +1 -1
  27. package/dist/default/SearchPage.d.ts +29 -1
  28. package/dist/default/SearchPage.d.ts.map +1 -1
  29. package/dist/default/SearchPage.js +25 -10
  30. package/dist/default/SearchPage.js.map +1 -1
  31. package/dist/default/SearchResultCard.d.ts.map +1 -1
  32. package/dist/default/SearchResultCard.js +83 -37
  33. package/dist/default/SearchResultCard.js.map +1 -1
  34. package/dist/default/SearchResultsPane.d.ts +22 -0
  35. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  36. package/dist/default/SearchResultsPane.js +2 -2
  37. package/dist/default/SearchResultsPane.js.map +1 -1
  38. package/dist/default/SortSelect.d.ts +23 -0
  39. package/dist/default/SortSelect.d.ts.map +1 -1
  40. package/dist/default/SortSelect.js +29 -9
  41. package/dist/default/SortSelect.js.map +1 -1
  42. package/dist/default/cardPhotos.d.ts +65 -0
  43. package/dist/default/cardPhotos.d.ts.map +1 -0
  44. package/dist/default/cardPhotos.js +127 -0
  45. package/dist/default/cardPhotos.js.map +1 -0
  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 +10 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +9 -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 +10 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +55 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +67 -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 +12 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +7 -5
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +4 -3
  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/model/runtime.d.ts +40 -3
  85. package/dist/model/runtime.d.ts.map +1 -1
  86. package/dist/model/runtime.js +2 -1
  87. package/dist/model/runtime.js.map +1 -1
  88. package/dist/nav/manifest.d.ts +21 -0
  89. package/dist/nav/manifest.d.ts.map +1 -1
  90. package/dist/nav/manifest.js +3 -2
  91. package/dist/nav/manifest.js.map +1 -1
  92. package/dist/state/facets.d.ts +56 -15
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +101 -27
  95. package/dist/state/facets.js.map +1 -1
  96. package/llms.txt +3 -3
  97. package/manifest.json +33 -4
  98. package/nav-manifest.json +4 -3
  99. package/package.json +11 -11
  100. package/src/analytics/generated/events.json +1 -1
  101. package/src/api/generated/schema.ts +60 -6
  102. package/src/api/searchApi.ts +13 -6
  103. package/src/api/types.ts +115 -1
  104. package/src/default/FacetPanelPane.tsx +16 -2
  105. package/src/default/FilterChips.tsx +285 -38
  106. package/src/default/LocationSummaryLine.tsx +6 -1
  107. package/src/default/SearchBox.tsx +134 -6
  108. package/src/default/SearchPage.tsx +79 -5
  109. package/src/default/SearchResultCard.tsx +157 -71
  110. package/src/default/SearchResultsPane.tsx +48 -11
  111. package/src/default/SortSelect.tsx +70 -9
  112. package/src/default/cardPhotos.ts +195 -0
  113. package/src/default/index.ts +10 -2
  114. package/src/headless/FacetPanel.tsx +20 -1
  115. package/src/headless/useFacetLabels.ts +172 -0
  116. package/src/headless/useSearchBox.ts +132 -5
  117. package/src/i18n/es.ts +11 -0
  118. package/src/i18n/keys.ts +69 -0
  119. package/src/i18n/ru.ts +13 -0
  120. package/src/index.ts +22 -3
  121. package/src/model/queries.ts +6 -5
  122. package/src/model/queryKeys.ts +23 -0
  123. package/src/model/runtime.ts +43 -4
  124. package/src/nav/manifest.ts +24 -2
  125. package/src/state/facets.ts +123 -39
@@ -8,30 +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. **Option LABELS come from two places, in that order.** The category's
12
- * feature schema`categoryFeatures`, the second slot-seam of the pair,
13
- * filled by the container from `categories-react` (spec §6.2 item 2) —
14
- * and, under it, the answer's own `facet_labels` (stapel-search 0.4.0+).
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.
15
14
  *
16
- * Until 0.4.0 the server sent `{value: count}` and nothing else, on the
17
- * reasoning that the container has the schema anyway. That reasoning had
18
- * a hole the size of a marketplace: the slot is OPTIONAL, a live
19
- * classified board never filled it, and its buyers read "Condition:
20
- * **b-u**", "Listing kind: **prodayu-svoe**", "Screen condition:
21
- * **bez-defektov**" on the SERP and in the filter chips. The schema still
22
- * wins where it resolves the client fetched it with its own
23
- * `Accept-Language` and the answer's captions are the floor beneath it.
24
- * Neither invents a label: a value nobody names renders as itself.
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.
25
30
  *
26
31
  * A slug the server SKIPPED (`facet_meta.skipped`, dropped at
27
32
  * `MAX_FACET_FIELDS`) is not counted at all. Its options carry `count: null`,
28
33
  * never `0` — "we did not count this" and "there are none" are different
29
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.
30
45
  */
31
- 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";
32
53
  import type { FeatureDef } from "@stapel/attributes-react";
33
54
  import type { FacetMeta, SearchQueryState } from "../api/types.js";
34
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
+
35
100
  /** One value of one facet. */
36
101
  export interface FacetOption {
37
102
  readonly value: string;
@@ -68,23 +133,25 @@ export interface BuildFacetGroupsInput {
68
133
  /** The category's feature schema, for labels and option order. */
69
134
  readonly categoryFeatures?: readonly FeatureDef[];
70
135
  /**
71
- * The envelope's `facet_labels` (stapel-search 0.4.0+):
72
- * `{slug: {translatable, values: {value: caption}}}`.
136
+ * The envelope's `facet_labels` (stapel-search 0.4.0+, vocabulary-backed
137
+ * from 0.6.0): `{slug: {translatable, values: {value: caption}}}`.
73
138
  *
74
- * The FLOOR under `categoryFeatures`, not a replacement for it. Both read
75
- * the same category config, but they read it differently: the client
76
- * fetches `GET /categories/{id}/features/` with its own `Accept-Language`,
77
- * so a host that threaded the schema through has the better-localized
78
- * copy and keeps it. What the answer's captions fix is the case where
79
- * there is no other copy at all — `categoryFeatures` is an OPTIONAL slot,
80
- * a live classified board never filled it, and its buyers read
81
- * "Condition: **b-u**" and "Listing kind: **prodayu-svoe**" on the SERP and in the
82
- * filter chips. A caption that arrives with the counts cannot be
83
- * forgotten by a host.
139
+ * The PRIMARY caption source, above `categoryFeatures`. Two reasons, and
140
+ * the second is the one that cannot be worked around:
84
141
  *
85
- * A slug neither side captions (a vocabulary-backed one, whose level lives
86
- * outside the category schema) falls through to the raw value. No labels
87
- * are invented at any step.
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.
88
155
  */
89
156
  readonly facetLabels?: Readonly<
90
157
  Record<
@@ -149,14 +216,19 @@ function declaredOptionValues(feature: FeatureDef | undefined): readonly string[
149
216
  }
150
217
 
151
218
  /**
152
- * One option's caption: schema, then answer, then the raw value.
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.
153
228
  *
154
- * Order matters and is deliberate. Both sources are the same category
155
- * config, but the client fetched its copy with its own `Accept-Language`, so
156
- * where the host threaded a schema through and it names the value, that is
157
- * the better-localized answer. Where it does not — no schema, a slug the
158
- * schema omits, a value added since — the server's caption is what stops
159
- * `b-u` reaching a buyer.
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.
160
232
  */
161
233
  function resolveLabel(
162
234
  input: BuildFacetGroupsInput,
@@ -165,9 +237,10 @@ function resolveLabel(
165
237
  value: string,
166
238
  labelOptions: { t?: (key: string) => string; locale?: string }
167
239
  ): string {
168
- const viaSchema = facetOptionLabel(feature, value, labelOptions);
169
- if (feature !== undefined && viaSchema !== value) return viaSchema;
170
- return serverLabel(input.facetLabels, slug, value, input.t) ?? viaSchema;
240
+ return (
241
+ serverLabel(input.facetLabels, slug, value, input.t) ??
242
+ facetOptionLabel(feature, value, labelOptions)
243
+ );
171
244
  }
172
245
 
173
246
  /**
@@ -231,6 +304,13 @@ export function facetOptionLabel(
231
304
  * person has filtered on — the last one matters: a filter whose slug fell out
232
305
  * of the plan must stay visible, or it becomes a constraint with no control to
233
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.
234
314
  */
235
315
  export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGroup[] {
236
316
  const bySlug = new Map<string, FeatureDef>();
@@ -246,6 +326,10 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
246
326
  ]) {
247
327
  if (seen.has(slug)) continue;
248
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;
249
333
  slugs.push(slug);
250
334
  }
251
335