@stapel/search-react 0.3.1 → 0.7.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 (153) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/MODULE.md +43 -4
  3. package/README.md +1 -1
  4. package/dist/api/generated/schema.d.ts +5 -1
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/DegradationNotice.d.ts +34 -0
  7. package/dist/default/DegradationNotice.d.ts.map +1 -1
  8. package/dist/default/DegradationNotice.js +28 -6
  9. package/dist/default/DegradationNotice.js.map +1 -1
  10. package/dist/default/FacetPanelPane.d.ts +54 -3
  11. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  12. package/dist/default/FacetPanelPane.js +119 -19
  13. package/dist/default/FacetPanelPane.js.map +1 -1
  14. package/dist/default/LanguageSelect.d.ts +9 -0
  15. package/dist/default/LanguageSelect.d.ts.map +1 -0
  16. package/dist/default/LanguageSelect.js +72 -0
  17. package/dist/default/LanguageSelect.js.map +1 -0
  18. package/dist/default/PageSizeSelect.d.ts +29 -0
  19. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  20. package/dist/default/PageSizeSelect.js +25 -0
  21. package/dist/default/PageSizeSelect.js.map +1 -0
  22. package/dist/default/RangeFilterRow.d.ts +12 -0
  23. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  24. package/dist/default/RangeFilterRow.js +75 -0
  25. package/dist/default/RangeFilterRow.js.map +1 -0
  26. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  27. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  28. package/dist/default/RankingDisclosurePane.js +15 -15
  29. package/dist/default/RankingDisclosurePane.js.map +1 -1
  30. package/dist/default/SearchBox.d.ts +13 -0
  31. package/dist/default/SearchBox.d.ts.map +1 -0
  32. package/dist/default/SearchBox.js +67 -0
  33. package/dist/default/SearchBox.js.map +1 -0
  34. package/dist/default/SearchPage.d.ts +58 -16
  35. package/dist/default/SearchPage.d.ts.map +1 -1
  36. package/dist/default/SearchPage.js +155 -6
  37. package/dist/default/SearchPage.js.map +1 -1
  38. package/dist/default/SearchResultCard.d.ts +19 -18
  39. package/dist/default/SearchResultCard.d.ts.map +1 -1
  40. package/dist/default/SearchResultCard.js +178 -5
  41. package/dist/default/SearchResultCard.js.map +1 -1
  42. package/dist/default/SearchResultsPane.d.ts +54 -3
  43. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  44. package/dist/default/SearchResultsPane.js +45 -25
  45. package/dist/default/SearchResultsPane.js.map +1 -1
  46. package/dist/default/SortSelect.d.ts +10 -2
  47. package/dist/default/SortSelect.d.ts.map +1 -1
  48. package/dist/default/SortSelect.js +27 -24
  49. package/dist/default/SortSelect.js.map +1 -1
  50. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  51. package/dist/default/UrlIssueNotice.js +19 -1
  52. package/dist/default/UrlIssueNotice.js.map +1 -1
  53. package/dist/default/index.d.ts +33 -16
  54. package/dist/default/index.d.ts.map +1 -1
  55. package/dist/default/index.js +26 -12
  56. package/dist/default/index.js.map +1 -1
  57. package/dist/default/sortLabels.d.ts +5 -0
  58. package/dist/default/sortLabels.d.ts.map +1 -0
  59. package/dist/default/sortLabels.js +32 -0
  60. package/dist/default/sortLabels.js.map +1 -0
  61. package/dist/headless/FacetPanel.d.ts +17 -0
  62. package/dist/headless/FacetPanel.d.ts.map +1 -1
  63. package/dist/headless/FacetPanel.js +23 -2
  64. package/dist/headless/FacetPanel.js.map +1 -1
  65. package/dist/headless/SearchResults.d.ts +20 -3
  66. package/dist/headless/SearchResults.d.ts.map +1 -1
  67. package/dist/headless/SearchResults.js +3 -1
  68. package/dist/headless/SearchResults.js.map +1 -1
  69. package/dist/headless/useAppliedCount.d.ts +8 -0
  70. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  71. package/dist/headless/useAppliedCount.js +30 -0
  72. package/dist/headless/useAppliedCount.js.map +1 -0
  73. package/dist/headless/useScorerNames.d.ts +4 -0
  74. package/dist/headless/useScorerNames.d.ts.map +1 -0
  75. package/dist/headless/useScorerNames.js +34 -0
  76. package/dist/headless/useScorerNames.js.map +1 -0
  77. package/dist/headless/useSearchBox.d.ts +44 -0
  78. package/dist/headless/useSearchBox.d.ts.map +1 -0
  79. package/dist/headless/useSearchBox.js +116 -0
  80. package/dist/headless/useSearchBox.js.map +1 -0
  81. package/dist/i18n/es.d.ts.map +1 -1
  82. package/dist/i18n/es.js +32 -4
  83. package/dist/i18n/es.js.map +1 -1
  84. package/dist/i18n/keys.d.ts +46 -2
  85. package/dist/i18n/keys.d.ts.map +1 -1
  86. package/dist/i18n/keys.js +86 -6
  87. package/dist/i18n/keys.js.map +1 -1
  88. package/dist/i18n/ru.d.ts.map +1 -1
  89. package/dist/i18n/ru.js +38 -4
  90. package/dist/i18n/ru.js.map +1 -1
  91. package/dist/index.d.ts +9 -3
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +6 -3
  94. package/dist/index.js.map +1 -1
  95. package/dist/model/queries.d.ts +26 -2
  96. package/dist/model/queries.d.ts.map +1 -1
  97. package/dist/model/queries.js +38 -1
  98. package/dist/model/queries.js.map +1 -1
  99. package/dist/state/degradations.d.ts +41 -0
  100. package/dist/state/degradations.d.ts.map +1 -1
  101. package/dist/state/degradations.js +41 -0
  102. package/dist/state/degradations.js.map +1 -1
  103. package/dist/state/limits.d.ts +44 -0
  104. package/dist/state/limits.d.ts.map +1 -0
  105. package/dist/state/limits.js +44 -0
  106. package/dist/state/limits.js.map +1 -0
  107. package/dist/state/ranges.d.ts +52 -0
  108. package/dist/state/ranges.d.ts.map +1 -0
  109. package/dist/state/ranges.js +108 -0
  110. package/dist/state/ranges.js.map +1 -0
  111. package/llms.txt +16 -5
  112. package/manifest.json +245 -22
  113. package/nav-manifest.json +1 -1
  114. package/package.json +15 -10
  115. package/src/analytics/generated/events.json +1 -1
  116. package/src/api/generated/schema.ts +5 -1
  117. package/src/default/DegradationNotice.tsx +84 -14
  118. package/src/default/FacetPanelPane.tsx +343 -64
  119. package/src/default/LanguageSelect.tsx +93 -0
  120. package/src/default/PageSizeSelect.tsx +80 -0
  121. package/src/default/RangeFilterRow.tsx +148 -0
  122. package/src/default/RankingDisclosurePane.tsx +67 -35
  123. package/src/default/SearchBox.tsx +130 -0
  124. package/src/default/SearchPage.tsx +356 -41
  125. package/src/default/SearchResultCard.tsx +231 -24
  126. package/src/default/SearchResultsPane.tsx +147 -76
  127. package/src/default/SortSelect.tsx +50 -37
  128. package/src/default/UrlIssueNotice.tsx +21 -3
  129. package/src/default/index.ts +42 -16
  130. package/src/default/sortLabels.ts +34 -0
  131. package/src/headless/FacetPanel.tsx +30 -2
  132. package/src/headless/SearchResults.tsx +32 -4
  133. package/src/headless/useAppliedCount.ts +41 -0
  134. package/src/headless/useScorerNames.ts +36 -0
  135. package/src/headless/useSearchBox.ts +189 -0
  136. package/src/i18n/es.ts +39 -4
  137. package/src/i18n/keys.ts +97 -6
  138. package/src/i18n/ru.ts +45 -4
  139. package/src/index.ts +30 -3
  140. package/src/model/queries.ts +51 -1
  141. package/src/state/degradations.ts +60 -0
  142. package/src/state/limits.ts +50 -0
  143. package/src/state/ranges.ts +144 -0
  144. package/dist/default/ErrorAlert.d.ts +0 -9
  145. package/dist/default/ErrorAlert.d.ts.map +0 -1
  146. package/dist/default/ErrorAlert.js +0 -27
  147. package/dist/default/ErrorAlert.js.map +0 -1
  148. package/dist/default/theme.d.ts +0 -24
  149. package/dist/default/theme.d.ts.map +0 -1
  150. package/dist/default/theme.js +0 -39
  151. package/dist/default/theme.js.map +0 -1
  152. package/src/default/ErrorAlert.tsx +0 -46
  153. package/src/default/theme.tsx +0 -66
package/src/i18n/es.ts CHANGED
@@ -22,6 +22,8 @@ export const searchI18nBundleEs: I18nDictionary = {
22
22
  "search.results.retry": "Reintentar",
23
23
  "search.results.count_approximate.one": "Aproximadamente {count} resultado",
24
24
  "search.results.count_approximate.other": "Unos {count} resultados",
25
+ "search.results.count_at_least.one": "{count}+ resultado",
26
+ "search.results.count_at_least.other": "{count}+ resultados",
25
27
  "search.results.count_exact.one": "{count} resultado",
26
28
  "search.results.count_exact.other": "{count} resultados",
27
29
  "search.results.took_ms": "{ms} ms",
@@ -37,6 +39,13 @@ export const searchI18nBundleEs: I18nDictionary = {
37
39
  "search.results.distance_km": "a {km} km",
38
40
  "search.results.untitled": "Sin título",
39
41
  "search.results.open": "Abrir",
42
+ "search.results.image_alt": "Foto de {title}",
43
+
44
+ "search.box.label": "Buscar",
45
+ "search.box.placeholder": "¿Qué estás buscando?",
46
+ "search.box.submit": "Buscar",
47
+ "search.box.clear": "Borrar la búsqueda",
48
+ "search.box.suggestions": "Sugerencias",
40
49
 
41
50
  "search.sort.label": "Orden",
42
51
  "search.sort.relevance": "Más relevantes",
@@ -62,21 +71,47 @@ export const searchI18nBundleEs: I18nDictionary = {
62
71
  "search.facets.range_from": "Desde",
63
72
  "search.facets.range_to": "Hasta",
64
73
  "search.facets.range_apply": "Aplicar",
74
+ "search.facets.range_clear": "Quitar",
75
+ "search.facets.range_from_aria": "{feature}, desde",
76
+ "search.facets.range_to_aria": "{feature}, hasta",
77
+ "search.facets.range_invalid":
78
+ "«Desde» es mayor que «hasta», así que nada podría coincidir. Intercámbialos para aplicar el rango.",
79
+
80
+ "search.filters.open": "Filtros ({count})",
81
+ "search.filters.apply": "Ver resultados",
82
+ "search.filters.show_count.one": "Ver {count} resultado",
83
+ "search.filters.show_count.other": "Ver {count} resultados",
84
+ "search.filters.show_count_at_least.one": "Ver {count}+ resultado",
85
+ "search.filters.show_count_at_least.other": "Ver {count}+ resultados",
86
+ "search.filters.dismiss": "Cerrar los filtros",
87
+
88
+ "search.category.title": "Categoría",
89
+ "search.category.clear": "Buscar en todo el catálogo",
90
+ "search.category.current": "Buscando dentro de {path}",
91
+
92
+ "search.language.label": "Idioma de la consulta",
93
+ "search.language.any": "Cualquier idioma",
94
+
95
+ "search.limit.label": "Por página",
96
+ "search.limit.option": "{count} por página",
97
+ "search.limit.from_link": "Este enlace fija su propio tamaño de página.",
65
98
 
66
99
  "search.geo.title": "Ubicación",
67
100
  "search.geo.radius_km": "A menos de {km} km",
101
+ "search.geo.radius_label": "Radio, km",
68
102
  "search.geo.clear": "En cualquier lugar",
69
103
  "search.geo.box": "Dentro del área mostrada",
70
104
  "search.geo.center": "Cerca de {lat}, {lon}",
71
105
 
72
106
  "search.url.issues_title": "Parte de este enlace no se pudo leer",
73
- "search.url.issue.not_a_number": "{param} no es un número y se ignoró",
107
+ "search.url.issue.not_a_number":
108
+ "«{param}» en este enlace no es un número, así que se ignoró",
74
109
  "search.url.issue.geo_incomplete":
75
- "la ubicación necesita lat y lon, así que se ignoró",
110
+ "la ubicación de este enlace está incompleta, así que se ignoró",
76
111
  "search.url.issue.bbox_malformed":
77
- "el área necesita cuatro números (minLat,minLon,maxLat,maxLon) y se ignoró",
112
+ "el área del mapa de este enlace está incompleta, así que se ignoró",
78
113
  "search.url.issue.range_malformed":
79
- "{param} no es un rango from..to y se ignoró",
114
+ "al rango «{param}» de este enlace le faltan números, así que se ignoró",
80
115
 
81
116
  "search.degraded.title": "Lo que esta búsqueda no pudo hacer",
82
117
  "search.degraded.typo_tolerance":
package/src/i18n/keys.ts CHANGED
@@ -28,9 +28,18 @@ export const SEARCH_I18N_KEYS = {
28
28
  resultsLoadFailed: "search.results.load_failed",
29
29
  resultsEmpty: "search.results.empty",
30
30
  resultsRetry: "search.results.retry",
31
- /** "About N results" — the sentence for an ESTIMATED count. A PLURAL
32
- * FAMILY: render with `tPlural`, never `t` (see SEARCH_I18N_PLURAL_KEYS). */
31
+ /**
32
+ * "About N results" the sentence for an ESTIMATED count. A PLURAL
33
+ * FAMILY: render with `tPlural`, never `t` (see SEARCH_I18N_PLURAL_KEYS).
34
+ *
35
+ * @deprecated stapel-search 0.2.0 stopped producing estimates: a count is
36
+ * exact, a FLOOR (`resultsCountAtLeast`), or absent. Kept so a host that
37
+ * translated it keeps a working key, and no longer rendered by the skin.
38
+ */
33
39
  resultsCountApproximate: "search.results.count_approximate",
40
+ /** "N+ results" — a LOWER BOUND: at least this many match, possibly more.
41
+ * A PLURAL FAMILY. Never render a floor as a plain number. */
42
+ resultsCountAtLeast: "search.results.count_at_least",
34
43
  /** "N results" — only when `exact_total` is true. A PLURAL FAMILY. */
35
44
  resultsCountExact: "search.results.count_exact",
36
45
  resultsTookMs: "search.results.took_ms",
@@ -45,6 +54,16 @@ export const SEARCH_I18N_KEYS = {
45
54
  resultsDistanceKm: "search.results.distance_km",
46
55
  resultsUntitled: "search.results.untitled",
47
56
  resultsOpen: "search.results.open",
57
+ /** Alt text for the card photo — the card's own title, in a sentence. */
58
+ resultsImageAlt: "search.results.image_alt",
59
+
60
+ // ── the query box ────────────────────────────────────────────────────────
61
+ boxLabel: "search.box.label",
62
+ boxPlaceholder: "search.box.placeholder",
63
+ boxSubmit: "search.box.submit",
64
+ boxClear: "search.box.clear",
65
+ /** Accessible name of the suggestion list under the box. */
66
+ boxSuggestions: "search.box.suggestions",
48
67
 
49
68
  // ── sort ─────────────────────────────────────────────────────────────────
50
69
  sortLabel: "search.sort.label",
@@ -70,10 +89,45 @@ export const SEARCH_I18N_KEYS = {
70
89
  facetsRangeFrom: "search.facets.range_from",
71
90
  facetsRangeTo: "search.facets.range_to",
72
91
  facetsRangeApply: "search.facets.range_apply",
92
+ facetsRangeClear: "search.facets.range_clear",
93
+ /** Accessible names: the visible "From"/"To" repeat on every row, so the
94
+ * field's own name has to carry the feature it belongs to. */
95
+ facetsRangeFromAria: "search.facets.range_from_aria",
96
+ facetsRangeToAria: "search.facets.range_to_aria",
97
+ /** The reason "Apply" is off: the range reads backwards. */
98
+ facetsRangeInvalid: "search.facets.range_invalid",
99
+
100
+ // ── the filter panel as a whole (phone sheet + host slots) ───────────────
101
+ filtersOpen: "search.filters.open",
102
+ /** The sheet's commit button when the count is unknown. */
103
+ filtersApply: "search.filters.apply",
104
+ /** "Show N results" — an EXACT count on the sheet's commit button.
105
+ * A PLURAL FAMILY. */
106
+ filtersShowCount: "search.filters.show_count",
107
+ /** "Show N+ results" — a FLOOR on the same button. A PLURAL FAMILY. */
108
+ filtersShowCountAtLeast: "search.filters.show_count_at_least",
109
+ filtersDismiss: "search.filters.dismiss",
110
+
111
+ // ── category (a host slot, plus the control that removes it) ─────────────
112
+ categoryTitle: "search.category.title",
113
+ categoryClear: "search.category.clear",
114
+ categoryCurrent: "search.category.current",
115
+
116
+ // ── language of the query ────────────────────────────────────────────────
117
+ languageLabel: "search.language.label",
118
+ languageAny: "search.language.any",
119
+
120
+ // ── page size ────────────────────────────────────────────────────────────
121
+ limitLabel: "search.limit.label",
122
+ limitOption: "search.limit.option",
123
+ /** Said beside the control when the URL carries a size the ladder does not
124
+ * offer — otherwise the story of "we kept your link's size" is invisible. */
125
+ limitFromLink: "search.limit.from_link",
73
126
 
74
127
  // ── geo ──────────────────────────────────────────────────────────────────
75
128
  geoTitle: "search.geo.title",
76
129
  geoRadiusKm: "search.geo.radius_km",
130
+ geoRadiusLabel: "search.geo.radius_label",
77
131
  geoClear: "search.geo.clear",
78
132
  geoBox: "search.geo.box",
79
133
  geoCenter: "search.geo.center",
@@ -129,7 +183,10 @@ export type SearchI18nKey =
129
183
  */
130
184
  export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
131
185
  SEARCH_I18N_KEYS.resultsCountApproximate,
186
+ SEARCH_I18N_KEYS.resultsCountAtLeast,
132
187
  SEARCH_I18N_KEYS.resultsCountExact,
188
+ SEARCH_I18N_KEYS.filtersShowCount,
189
+ SEARCH_I18N_KEYS.filtersShowCountAtLeast,
133
190
  ];
134
191
 
135
192
  /**
@@ -149,6 +206,8 @@ export const searchI18nBundleEn: Record<string, string> = {
149
206
  "search.results.retry": "Try again",
150
207
  "search.results.count_approximate.one": "About {count} result",
151
208
  "search.results.count_approximate.other": "About {count} results",
209
+ "search.results.count_at_least.one": "{count}+ result",
210
+ "search.results.count_at_least.other": "{count}+ results",
152
211
  "search.results.count_exact.one": "{count} result",
153
212
  "search.results.count_exact.other": "{count} results",
154
213
  "search.results.took_ms": "{ms} ms",
@@ -164,6 +223,13 @@ export const searchI18nBundleEn: Record<string, string> = {
164
223
  "search.results.distance_km": "{km} km away",
165
224
  "search.results.untitled": "Untitled",
166
225
  "search.results.open": "Open",
226
+ "search.results.image_alt": "Photo of {title}",
227
+
228
+ "search.box.label": "Search",
229
+ "search.box.placeholder": "What are you looking for?",
230
+ "search.box.submit": "Search",
231
+ "search.box.clear": "Clear the search",
232
+ "search.box.suggestions": "Suggestions",
167
233
 
168
234
  "search.sort.label": "Sort",
169
235
  "search.sort.relevance": "Most relevant",
@@ -189,21 +255,46 @@ export const searchI18nBundleEn: Record<string, string> = {
189
255
  "search.facets.range_from": "From",
190
256
  "search.facets.range_to": "To",
191
257
  "search.facets.range_apply": "Apply",
258
+ "search.facets.range_clear": "Clear",
259
+ "search.facets.range_from_aria": "{feature}, from",
260
+ "search.facets.range_to_aria": "{feature}, up to",
261
+ "search.facets.range_invalid":
262
+ "“From” is larger than “to”, so nothing could match. Swap them to apply this range.",
263
+
264
+ "search.filters.open": "Filters ({count})",
265
+ "search.filters.apply": "Show results",
266
+ "search.filters.show_count.one": "Show {count} result",
267
+ "search.filters.show_count.other": "Show {count} results",
268
+ "search.filters.show_count_at_least.one": "Show {count}+ result",
269
+ "search.filters.show_count_at_least.other": "Show {count}+ results",
270
+ "search.filters.dismiss": "Close the filters",
271
+
272
+ "search.category.title": "Category",
273
+ "search.category.clear": "Search the whole catalogue",
274
+ "search.category.current": "Searching inside {path}",
275
+
276
+ "search.language.label": "Query language",
277
+ "search.language.any": "Any language",
278
+
279
+ "search.limit.label": "Per page",
280
+ "search.limit.option": "{count} per page",
281
+ "search.limit.from_link": "This link sets its own page size.",
192
282
 
193
283
  "search.geo.title": "Location",
194
284
  "search.geo.radius_km": "Within {km} km",
285
+ "search.geo.radius_label": "Radius, km",
195
286
  "search.geo.clear": "Anywhere",
196
287
  "search.geo.box": "Inside the shown area",
197
288
  "search.geo.center": "Around {lat}, {lon}",
198
289
 
199
290
  "search.url.issues_title": "Part of this link could not be read",
200
- "search.url.issue.not_a_number": "{param} is not a number and was ignored",
291
+ "search.url.issue.not_a_number": "{param} in this link is not a number, so it was ignored",
201
292
  "search.url.issue.geo_incomplete":
202
- "the location needs both lat and lon, so it was ignored",
293
+ "the location in this link is only half there, so it was ignored",
203
294
  "search.url.issue.bbox_malformed":
204
- "the area needs four numbers (minLat,minLon,maxLat,maxLon) and was ignored",
295
+ "the map area in this link is incomplete, so it was ignored",
205
296
  "search.url.issue.range_malformed":
206
- "{param} is not a from..to range and was ignored",
297
+ "the range “{param} in this link needs two numbers, so it was ignored",
207
298
 
208
299
  "search.degraded.title": "What this search could not do",
209
300
  "search.degraded.typo_tolerance":
package/src/i18n/ru.ts CHANGED
@@ -41,6 +41,10 @@ export const searchI18nBundleRu: I18nDictionary = {
41
41
  "search.results.count_approximate.few": "Примерно {count} объявления",
42
42
  "search.results.count_approximate.many": "Примерно {count} объявлений",
43
43
  "search.results.count_approximate.other": "Примерно {count} объявления",
44
+ "search.results.count_at_least.one": "{count}+ объявление",
45
+ "search.results.count_at_least.few": "{count}+ объявления",
46
+ "search.results.count_at_least.many": "{count}+ объявлений",
47
+ "search.results.count_at_least.other": "{count}+ объявления",
44
48
  "search.results.count_exact.one": "{count} объявление",
45
49
  "search.results.count_exact.few": "{count} объявления",
46
50
  "search.results.count_exact.many": "{count} объявлений",
@@ -58,6 +62,13 @@ export const searchI18nBundleRu: I18nDictionary = {
58
62
  "search.results.distance_km": "{km} км от вас",
59
63
  "search.results.untitled": "Без названия",
60
64
  "search.results.open": "Открыть",
65
+ "search.results.image_alt": "Фото: {title}",
66
+
67
+ "search.box.label": "Поиск",
68
+ "search.box.placeholder": "Что ищете?",
69
+ "search.box.submit": "Найти",
70
+ "search.box.clear": "Очистить запрос",
71
+ "search.box.suggestions": "Подсказки",
61
72
 
62
73
  "search.sort.label": "Сортировка",
63
74
  "search.sort.relevance": "По релевантности",
@@ -83,21 +94,51 @@ export const searchI18nBundleRu: I18nDictionary = {
83
94
  "search.facets.range_from": "От",
84
95
  "search.facets.range_to": "До",
85
96
  "search.facets.range_apply": "Применить",
97
+ "search.facets.range_clear": "Сбросить",
98
+ "search.facets.range_from_aria": "{feature}, от",
99
+ "search.facets.range_to_aria": "{feature}, до",
100
+ "search.facets.range_invalid":
101
+ "«От» больше, чем «до», — под такой диапазон ничего не подойдёт. Поменяйте значения местами.",
102
+
103
+ "search.filters.open": "Фильтры ({count})",
104
+ "search.filters.apply": "Показать результаты",
105
+ "search.filters.show_count.one": "Показать {count} объявление",
106
+ "search.filters.show_count.few": "Показать {count} объявления",
107
+ "search.filters.show_count.many": "Показать {count} объявлений",
108
+ "search.filters.show_count.other": "Показать {count} объявления",
109
+ "search.filters.show_count_at_least.one": "Показать {count}+ объявление",
110
+ "search.filters.show_count_at_least.few": "Показать {count}+ объявления",
111
+ "search.filters.show_count_at_least.many": "Показать {count}+ объявлений",
112
+ "search.filters.show_count_at_least.other": "Показать {count}+ объявления",
113
+ "search.filters.dismiss": "Закрыть фильтры",
114
+
115
+ "search.category.title": "Категория",
116
+ "search.category.clear": "Искать по всему каталогу",
117
+ "search.category.current": "Ищем внутри {path}",
118
+
119
+ "search.language.label": "Язык запроса",
120
+ "search.language.any": "Любой язык",
121
+
122
+ "search.limit.label": "На странице",
123
+ "search.limit.option": "{count} на странице",
124
+ "search.limit.from_link": "Размер страницы задан этой ссылкой.",
86
125
 
87
126
  "search.geo.title": "Где искать",
88
127
  "search.geo.radius_km": "В радиусе {km} км",
128
+ "search.geo.radius_label": "Радиус, км",
89
129
  "search.geo.clear": "Везде",
90
130
  "search.geo.box": "В показанной области",
91
131
  "search.geo.center": "Рядом с {lat}, {lon}",
92
132
 
93
133
  "search.url.issues_title": "Часть этой ссылки прочитать не удалось",
94
- "search.url.issue.not_a_number": "{param} — не число, параметр пропущен",
134
+ "search.url.issue.not_a_number":
135
+ "«{param}» в этой ссылке — не число, поэтому параметр пропущен",
95
136
  "search.url.issue.geo_incomplete":
96
- "для точки нужны и lat, и lon параметр пропущен",
137
+ "точка в этой ссылке указана наполовину, поэтому она пропущена",
97
138
  "search.url.issue.bbox_malformed":
98
- "для области нужны четыре числа (minLat,minLon,maxLat,maxLon) параметр пропущен",
139
+ "область на карте в этой ссылке указана не полностью, поэтому она пропущена",
99
140
  "search.url.issue.range_malformed":
100
- "{param} не диапазон вида from..to, параметр пропущен",
141
+ "диапазону «{param}» в этой ссылке не хватает чисел, поэтому он пропущен",
101
142
 
102
143
  "search.degraded.title": "Что этот поиск не смог сделать",
103
144
  "search.degraded.typo_tolerance":
package/src/index.ts CHANGED
@@ -86,7 +86,14 @@ export type {
86
86
  SearchStatePatch,
87
87
  } from "./state/urlState.js";
88
88
 
89
- export { countIsEstimate, degradationMessageKey, parseDegradations } from "./state/degradations.js";
89
+ export {
90
+ countIsEstimate,
91
+ countKind,
92
+ degradationMessageKey,
93
+ isCountNuanceOnly,
94
+ parseDegradations,
95
+ } from "./state/degradations.js";
96
+ export type { SearchCountKind } from "./state/degradations.js";
90
97
 
91
98
  export { buildFacetGroups, facetOptionLabel } from "./state/facets.js";
92
99
  export type {
@@ -95,6 +102,24 @@ export type {
95
102
  FacetOption,
96
103
  } from "./state/facets.js";
97
104
 
105
+ export {
106
+ RANGE_FEATURE_TYPES,
107
+ buildRangeGroups,
108
+ isRangeFeature,
109
+ isRangeUsable,
110
+ } from "./state/ranges.js";
111
+ export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
112
+
113
+ export {
114
+ SEARCH_BOX_DEBOUNCE_MS,
115
+ SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
116
+ SEARCH_DEFAULT_PAGE_SIZE,
117
+ SEARCH_MAX_PAGE_SIZE,
118
+ SEARCH_QUERY_MAX_CHARS,
119
+ SUGGEST_MAX_LIMIT,
120
+ SUGGEST_MIN_CHARS,
121
+ } from "./state/limits.js";
122
+
98
123
  // ── model (runtime wiring, query keys, context, hooks) ───────────────────────
99
124
  export { createSearchRuntime } from "./model/runtime.js";
100
125
  export type {
@@ -110,7 +135,7 @@ export {
110
135
  export { searchQueryKeys } from "./model/queryKeys.js";
111
136
  export type { SearchQueryKeyParams } from "./model/queryKeys.js";
112
137
 
113
- export { useRankingDisclosure, useSearchQuery } from "./model/queries.js";
138
+ export { useRankingDisclosure, useSearchQuery, useSuggest } from "./model/queries.js";
114
139
 
115
140
  // ── headless (renderless components) ─────────────────────────────────────────
116
141
  export { SearchProvider } from "./headless/SearchProvider.js";
@@ -122,9 +147,11 @@ export type {
122
147
  } from "./headless/SearchStateProvider.js";
123
148
  export { SearchResults } from "./headless/SearchResults.js";
124
149
  export type { SearchPageInfo, SearchResultsBag } from "./headless/SearchResults.js";
125
- export { FacetPanel } from "./headless/FacetPanel.js";
150
+ export { FacetPanel, useFacetPanel } from "./headless/FacetPanel.js";
126
151
  export type { FacetPanelBag } from "./headless/FacetPanel.js";
127
152
  export { useAppliedSort } from "./headless/useAppliedSort.js";
153
+ export { useSearchBox } from "./headless/useSearchBox.js";
154
+ export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
128
155
  export { RankingDisclosure } from "./headless/RankingDisclosure.js";
129
156
  export type { RankingDisclosureBag } from "./headless/RankingDisclosure.js";
130
157
 
@@ -6,7 +6,9 @@ import type {
6
6
  RankingResponse,
7
7
  SearchQueryState,
8
8
  SearchResponse,
9
+ SuggestResponse,
9
10
  } from "../api/types.js";
11
+ import { SUGGEST_MAX_LIMIT, SUGGEST_MIN_CHARS } from "../state/limits.js";
10
12
  import { useSearchApi } from "./context.js";
11
13
  import { searchQueryKeys } from "./queryKeys.js";
12
14
 
@@ -60,6 +62,52 @@ export function useSearchQuery(
60
62
  });
61
63
  }
62
64
 
65
+ /**
66
+ * Title prefixes from the index, for the search box's typeahead.
67
+ *
68
+ * Not from a query log: stapel-search keeps none, which is a privacy decision
69
+ * before it is a product one (`services.suggest`). So the list is what exists
70
+ * in the catalogue, and choosing one is always a search that has results.
71
+ *
72
+ * `enabled` is the debounce's partner, not its replacement: the CALLER holds a
73
+ * debounced prefix (see `useSearchBox`) and this hook refuses to ask about a
74
+ * prefix too short to mean anything. `placeholderData: keepPreviousData` keeps
75
+ * the previous list under the cursor while the next one is in flight — a menu
76
+ * that empties and refills on every keystroke is a menu nobody can click.
77
+ *
78
+ * `staleTime` is a minute: the index changes when something is published, not
79
+ * between two letters of one word.
80
+ */
81
+ export function useSuggest(params: {
82
+ readonly type: string;
83
+ readonly q: string;
84
+ readonly limit?: number;
85
+ readonly enabled?: boolean;
86
+ }): UseQueryResult<SuggestResponse, StapelApiError> {
87
+ const api = useSearchApi();
88
+ const q = params.q.trim();
89
+ const limit = params.limit;
90
+ return useQuery({
91
+ queryKey: searchQueryKeys.suggest(params.type, q, limit),
92
+ queryFn: ({ signal }) =>
93
+ api.suggest(
94
+ {
95
+ type: params.type,
96
+ q,
97
+ ...(limit !== undefined ? { limit: Math.min(limit, SUGGEST_MAX_LIMIT) } : {}),
98
+ },
99
+ { signal }
100
+ ),
101
+ enabled:
102
+ (params.enabled ?? true) &&
103
+ params.type.length > 0 &&
104
+ q.length >= SUGGEST_MIN_CHARS,
105
+ placeholderData: keepPreviousData,
106
+ staleTime: 60_000,
107
+ retry: false,
108
+ });
109
+ }
110
+
63
111
  /**
64
112
  * The P2B Art. 5 ranking disclosure.
65
113
  *
@@ -67,12 +115,14 @@ export function useSearchQuery(
67
115
  * not while a person reads the page it explains.
68
116
  */
69
117
  export function useRankingDisclosure(
70
- type?: string
118
+ type?: string,
119
+ options?: { readonly enabled?: boolean }
71
120
  ): UseQueryResult<RankingResponse, StapelApiError> {
72
121
  const api = useSearchApi();
73
122
  return useQuery({
74
123
  queryKey: searchQueryKeys.ranking(type),
75
124
  queryFn: ({ signal }) => api.ranking(type, { signal }),
125
+ enabled: options?.enabled ?? true,
76
126
  staleTime: Number.POSITIVE_INFINITY,
77
127
  retry: false,
78
128
  });
@@ -66,6 +66,11 @@ export function parseDegradations(
66
66
  * the envelope's own `exact_total: false`, and the `"exact_total"`
67
67
  * degradation. They agree in practice; treating either as decisive is what
68
68
  * keeps "≈1 200" from ever being rendered as "1 200".
69
+ *
70
+ * @deprecated Since stapel-search 0.2.0 the envelope answers this directly
71
+ * with `count_is_lower_bound`, and "not exact" now means a FLOOR rather than
72
+ * a fuzzy number. Use {@link countKind}, which reads the wire field and also
73
+ * handles `count: null`.
69
74
  */
70
75
  export function countIsEstimate(
71
76
  exactTotal: boolean,
@@ -73,3 +78,58 @@ export function countIsEstimate(
73
78
  ): boolean {
74
79
  return !exactTotal || degradations.some((d) => d.kind === "exact_total");
75
80
  }
81
+
82
+ /**
83
+ * How a count may be spoken: as a number, as a floor, or not at all.
84
+ *
85
+ * The three states stapel-search 0.2.0 made explicit, because the version
86
+ * before it had only two and used `0` for both "none match" and "we do not
87
+ * know" — which is how a storefront came to print "About 0 listings" over
88
+ * four visible cards.
89
+ */
90
+ export type SearchCountKind = "exact" | "at_least" | "unknown";
91
+
92
+ /**
93
+ * Read the envelope's three count fields as one decision.
94
+ *
95
+ * `null` is UNKNOWN and renders as no count line — never as `0`, and never
96
+ * as "about 0". Anything the server declines to call exact is rendered as a
97
+ * floor ("N+"), including the defensive case of an `exact_total: false` from
98
+ * a server that predates `count_is_lower_bound`: claiming "at least N" over
99
+ * a page that shows N rows is the one reading that cannot be contradicted by
100
+ * what the reader can see.
101
+ */
102
+ export function countKind(
103
+ count: number | null | undefined,
104
+ isLowerBound: boolean | undefined,
105
+ exactTotal: boolean,
106
+ degradations: readonly SearchDegradation[]
107
+ ): SearchCountKind {
108
+ if (count === null || count === undefined) return "unknown";
109
+ if (isLowerBound === true) return "at_least";
110
+ return countIsEstimate(exactTotal, degradations) ? "at_least" : "exact";
111
+ }
112
+
113
+ /**
114
+ * Is `exact_total` the ONLY thing the engine could not do?
115
+ *
116
+ * It is a count NUANCE, not a failed search: the answer is complete, the
117
+ * rows are the right rows, and the single consequence — that the total is a
118
+ * floor — is already spoken by the count itself as "N+". Raising a warning
119
+ * banner over it teaches a reader that a perfectly good result page is
120
+ * broken, and a banner that cries wolf on every landing page is a banner
121
+ * nobody reads on the day `category_rollup` shows up in it.
122
+ *
123
+ * Every other degradation changes what the page MEANS ("typos were not
124
+ * corrected", "subcategories may be missing", "counts are approximate") and
125
+ * still renders. So does `exact_total` when it arrives BESIDE one of them —
126
+ * the list is then a description of a genuinely degraded answer.
127
+ */
128
+ export function isCountNuanceOnly(
129
+ degradations: readonly SearchDegradation[]
130
+ ): boolean {
131
+ return (
132
+ degradations.length > 0 &&
133
+ degradations.every((degradation) => degradation.kind === "exact_total")
134
+ );
135
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The backend's own limits, in one place — and the two timings the client
3
+ * chooses so that it never provokes them.
4
+ *
5
+ * Every number here is a fact about stapel-search's `conf.py` (or, for the
6
+ * debounces, a decision taken because of one). They live in the state layer,
7
+ * beside the codec, because they are properties of the CONTRACT rather than of
8
+ * any component: a control that caps its input at `SEARCH_QUERY_MAX_CHARS`
9
+ * cannot produce `error.400.search_query_too_long`, and a typeahead that waits
10
+ * for `SEARCH_BOX_SUGGEST_DEBOUNCE_MS` does not earn a 429 from a throttled
11
+ * endpoint for typing quickly.
12
+ *
13
+ * They are DEFAULTS, not assertions: a deployment may raise `MAX_PAGE_SIZE` or
14
+ * lower `MAX_QUERY_CHARS`, and nothing here is sent to the server as a claim.
15
+ * The server remains the one entitled to refuse.
16
+ */
17
+
18
+ /** `MAX_QUERY_CHARS` — the longest `q` (and suggest prefix) the server reads. */
19
+ export const SEARCH_QUERY_MAX_CHARS = 200;
20
+
21
+ /** `DEFAULT_PAGE_SIZE` — what a request with no `limit` gets. */
22
+ export const SEARCH_DEFAULT_PAGE_SIZE = 24;
23
+
24
+ /** `MAX_PAGE_SIZE` — the server clamps anything above this. */
25
+ export const SEARCH_MAX_PAGE_SIZE = 100;
26
+
27
+ /** `services.suggest` clamps `limit` into `1..25`. */
28
+ export const SUGGEST_MAX_LIMIT = 25;
29
+
30
+ /**
31
+ * The shortest prefix worth asking the index about.
32
+ *
33
+ * One and two letters match nearly everything, so the answer is noise and the
34
+ * request is a keystroke tax on a throttled endpoint (`SuggestThrottle`,
35
+ * scope `search-suggest`). Three is where a prefix starts to mean something.
36
+ */
37
+ export const SUGGEST_MIN_CHARS = 3;
38
+
39
+ /** How long the typing has to stop before the SEARCH runs. */
40
+ export const SEARCH_BOX_DEBOUNCE_MS = 350;
41
+
42
+ /**
43
+ * How long it has to stop before the INDEX is asked for prefixes.
44
+ *
45
+ * Shorter than the commit: a suggestion is only worth having while you are
46
+ * still typing, and it costs a cheap read of one column. Longer than zero: the
47
+ * endpoint is throttled per client, and a request per keystroke is how a person
48
+ * gets 429s for typing quickly.
49
+ */
50
+ export const SEARCH_BOX_SUGGEST_DEBOUNCE_MS = 150;