@stapel/search-react 0.4.0 → 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 (139) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +1 -1
  3. package/dist/default/DegradationNotice.d.ts +7 -0
  4. package/dist/default/DegradationNotice.d.ts.map +1 -1
  5. package/dist/default/DegradationNotice.js +17 -6
  6. package/dist/default/DegradationNotice.js.map +1 -1
  7. package/dist/default/FacetPanelPane.d.ts +54 -3
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +119 -19
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/LanguageSelect.d.ts +9 -0
  12. package/dist/default/LanguageSelect.d.ts.map +1 -0
  13. package/dist/default/LanguageSelect.js +72 -0
  14. package/dist/default/LanguageSelect.js.map +1 -0
  15. package/dist/default/PageSizeSelect.d.ts +29 -0
  16. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  17. package/dist/default/PageSizeSelect.js +25 -0
  18. package/dist/default/PageSizeSelect.js.map +1 -0
  19. package/dist/default/RangeFilterRow.d.ts +12 -0
  20. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  21. package/dist/default/RangeFilterRow.js +75 -0
  22. package/dist/default/RangeFilterRow.js.map +1 -0
  23. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  24. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  25. package/dist/default/RankingDisclosurePane.js +15 -15
  26. package/dist/default/RankingDisclosurePane.js.map +1 -1
  27. package/dist/default/SearchBox.d.ts +13 -0
  28. package/dist/default/SearchBox.d.ts.map +1 -0
  29. package/dist/default/SearchBox.js +67 -0
  30. package/dist/default/SearchBox.js.map +1 -0
  31. package/dist/default/SearchPage.d.ts +54 -16
  32. package/dist/default/SearchPage.d.ts.map +1 -1
  33. package/dist/default/SearchPage.js +155 -8
  34. package/dist/default/SearchPage.js.map +1 -1
  35. package/dist/default/SearchResultCard.d.ts +19 -18
  36. package/dist/default/SearchResultCard.d.ts.map +1 -1
  37. package/dist/default/SearchResultCard.js +178 -5
  38. package/dist/default/SearchResultCard.js.map +1 -1
  39. package/dist/default/SearchResultsPane.d.ts +42 -3
  40. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  41. package/dist/default/SearchResultsPane.js +38 -21
  42. package/dist/default/SearchResultsPane.js.map +1 -1
  43. package/dist/default/SortSelect.d.ts +10 -2
  44. package/dist/default/SortSelect.d.ts.map +1 -1
  45. package/dist/default/SortSelect.js +27 -24
  46. package/dist/default/SortSelect.js.map +1 -1
  47. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  48. package/dist/default/UrlIssueNotice.js +19 -1
  49. package/dist/default/UrlIssueNotice.js.map +1 -1
  50. package/dist/default/index.d.ts +32 -15
  51. package/dist/default/index.d.ts.map +1 -1
  52. package/dist/default/index.js +26 -12
  53. package/dist/default/index.js.map +1 -1
  54. package/dist/default/sortLabels.d.ts +5 -0
  55. package/dist/default/sortLabels.d.ts.map +1 -0
  56. package/dist/default/sortLabels.js +32 -0
  57. package/dist/default/sortLabels.js.map +1 -0
  58. package/dist/headless/FacetPanel.d.ts +17 -0
  59. package/dist/headless/FacetPanel.d.ts.map +1 -1
  60. package/dist/headless/FacetPanel.js +23 -2
  61. package/dist/headless/FacetPanel.js.map +1 -1
  62. package/dist/headless/useAppliedCount.d.ts +8 -0
  63. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  64. package/dist/headless/useAppliedCount.js +30 -0
  65. package/dist/headless/useAppliedCount.js.map +1 -0
  66. package/dist/headless/useScorerNames.d.ts +4 -0
  67. package/dist/headless/useScorerNames.d.ts.map +1 -0
  68. package/dist/headless/useScorerNames.js +34 -0
  69. package/dist/headless/useScorerNames.js.map +1 -0
  70. package/dist/headless/useSearchBox.d.ts +44 -0
  71. package/dist/headless/useSearchBox.d.ts.map +1 -0
  72. package/dist/headless/useSearchBox.js +116 -0
  73. package/dist/headless/useSearchBox.js.map +1 -0
  74. package/dist/i18n/es.d.ts.map +1 -1
  75. package/dist/i18n/es.js +30 -4
  76. package/dist/i18n/es.js.map +1 -1
  77. package/dist/i18n/keys.d.ts +35 -0
  78. package/dist/i18n/keys.d.ts.map +1 -1
  79. package/dist/i18n/keys.js +72 -4
  80. package/dist/i18n/keys.js.map +1 -1
  81. package/dist/i18n/ru.d.ts.map +1 -1
  82. package/dist/i18n/ru.js +34 -4
  83. package/dist/i18n/ru.js.map +1 -1
  84. package/dist/index.d.ts +7 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +5 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/model/queries.d.ts +26 -2
  89. package/dist/model/queries.d.ts.map +1 -1
  90. package/dist/model/queries.js +38 -1
  91. package/dist/model/queries.js.map +1 -1
  92. package/dist/state/limits.d.ts +44 -0
  93. package/dist/state/limits.d.ts.map +1 -0
  94. package/dist/state/limits.js +44 -0
  95. package/dist/state/limits.js.map +1 -0
  96. package/dist/state/ranges.d.ts +52 -0
  97. package/dist/state/ranges.d.ts.map +1 -0
  98. package/dist/state/ranges.js +108 -0
  99. package/dist/state/ranges.js.map +1 -0
  100. package/llms.txt +15 -4
  101. package/manifest.json +238 -21
  102. package/nav-manifest.json +1 -1
  103. package/package.json +15 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/default/DegradationNotice.tsx +34 -12
  106. package/src/default/FacetPanelPane.tsx +343 -64
  107. package/src/default/LanguageSelect.tsx +93 -0
  108. package/src/default/PageSizeSelect.tsx +80 -0
  109. package/src/default/RangeFilterRow.tsx +148 -0
  110. package/src/default/RankingDisclosurePane.tsx +67 -35
  111. package/src/default/SearchBox.tsx +130 -0
  112. package/src/default/SearchPage.tsx +351 -44
  113. package/src/default/SearchResultCard.tsx +231 -24
  114. package/src/default/SearchResultsPane.tsx +120 -70
  115. package/src/default/SortSelect.tsx +50 -37
  116. package/src/default/UrlIssueNotice.tsx +21 -3
  117. package/src/default/index.ts +38 -15
  118. package/src/default/sortLabels.ts +34 -0
  119. package/src/headless/FacetPanel.tsx +30 -2
  120. package/src/headless/useAppliedCount.ts +41 -0
  121. package/src/headless/useScorerNames.ts +36 -0
  122. package/src/headless/useSearchBox.ts +189 -0
  123. package/src/i18n/es.ts +37 -4
  124. package/src/i18n/keys.ts +83 -4
  125. package/src/i18n/ru.ts +41 -4
  126. package/src/index.ts +22 -2
  127. package/src/model/queries.ts +51 -1
  128. package/src/state/limits.ts +50 -0
  129. package/src/state/ranges.ts +144 -0
  130. package/dist/default/ErrorAlert.d.ts +0 -9
  131. package/dist/default/ErrorAlert.d.ts.map +0 -1
  132. package/dist/default/ErrorAlert.js +0 -27
  133. package/dist/default/ErrorAlert.js.map +0 -1
  134. package/dist/default/theme.d.ts +0 -24
  135. package/dist/default/theme.d.ts.map +0 -1
  136. package/dist/default/theme.js +0 -39
  137. package/dist/default/theme.js.map +0 -1
  138. package/src/default/ErrorAlert.tsx +0 -46
  139. package/src/default/theme.tsx +0 -66
package/src/i18n/keys.ts CHANGED
@@ -54,6 +54,16 @@ export const SEARCH_I18N_KEYS = {
54
54
  resultsDistanceKm: "search.results.distance_km",
55
55
  resultsUntitled: "search.results.untitled",
56
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",
57
67
 
58
68
  // ── sort ─────────────────────────────────────────────────────────────────
59
69
  sortLabel: "search.sort.label",
@@ -79,10 +89,45 @@ export const SEARCH_I18N_KEYS = {
79
89
  facetsRangeFrom: "search.facets.range_from",
80
90
  facetsRangeTo: "search.facets.range_to",
81
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",
82
126
 
83
127
  // ── geo ──────────────────────────────────────────────────────────────────
84
128
  geoTitle: "search.geo.title",
85
129
  geoRadiusKm: "search.geo.radius_km",
130
+ geoRadiusLabel: "search.geo.radius_label",
86
131
  geoClear: "search.geo.clear",
87
132
  geoBox: "search.geo.box",
88
133
  geoCenter: "search.geo.center",
@@ -140,6 +185,8 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
140
185
  SEARCH_I18N_KEYS.resultsCountApproximate,
141
186
  SEARCH_I18N_KEYS.resultsCountAtLeast,
142
187
  SEARCH_I18N_KEYS.resultsCountExact,
188
+ SEARCH_I18N_KEYS.filtersShowCount,
189
+ SEARCH_I18N_KEYS.filtersShowCountAtLeast,
143
190
  ];
144
191
 
145
192
  /**
@@ -176,6 +223,13 @@ export const searchI18nBundleEn: Record<string, string> = {
176
223
  "search.results.distance_km": "{km} km away",
177
224
  "search.results.untitled": "Untitled",
178
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",
179
233
 
180
234
  "search.sort.label": "Sort",
181
235
  "search.sort.relevance": "Most relevant",
@@ -201,21 +255,46 @@ export const searchI18nBundleEn: Record<string, string> = {
201
255
  "search.facets.range_from": "From",
202
256
  "search.facets.range_to": "To",
203
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.",
204
282
 
205
283
  "search.geo.title": "Location",
206
284
  "search.geo.radius_km": "Within {km} km",
285
+ "search.geo.radius_label": "Radius, km",
207
286
  "search.geo.clear": "Anywhere",
208
287
  "search.geo.box": "Inside the shown area",
209
288
  "search.geo.center": "Around {lat}, {lon}",
210
289
 
211
290
  "search.url.issues_title": "Part of this link could not be read",
212
- "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",
213
292
  "search.url.issue.geo_incomplete":
214
- "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",
215
294
  "search.url.issue.bbox_malformed":
216
- "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",
217
296
  "search.url.issue.range_malformed":
218
- "{param} is not a from..to range and was ignored",
297
+ "the range “{param} in this link needs two numbers, so it was ignored",
219
298
 
220
299
  "search.degraded.title": "What this search could not do",
221
300
  "search.degraded.typo_tolerance":
package/src/i18n/ru.ts CHANGED
@@ -62,6 +62,13 @@ export const searchI18nBundleRu: I18nDictionary = {
62
62
  "search.results.distance_km": "{km} км от вас",
63
63
  "search.results.untitled": "Без названия",
64
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": "Подсказки",
65
72
 
66
73
  "search.sort.label": "Сортировка",
67
74
  "search.sort.relevance": "По релевантности",
@@ -87,21 +94,51 @@ export const searchI18nBundleRu: I18nDictionary = {
87
94
  "search.facets.range_from": "От",
88
95
  "search.facets.range_to": "До",
89
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": "Размер страницы задан этой ссылкой.",
90
125
 
91
126
  "search.geo.title": "Где искать",
92
127
  "search.geo.radius_km": "В радиусе {km} км",
128
+ "search.geo.radius_label": "Радиус, км",
93
129
  "search.geo.clear": "Везде",
94
130
  "search.geo.box": "В показанной области",
95
131
  "search.geo.center": "Рядом с {lat}, {lon}",
96
132
 
97
133
  "search.url.issues_title": "Часть этой ссылки прочитать не удалось",
98
- "search.url.issue.not_a_number": "{param} — не число, параметр пропущен",
134
+ "search.url.issue.not_a_number":
135
+ "«{param}» в этой ссылке — не число, поэтому параметр пропущен",
99
136
  "search.url.issue.geo_incomplete":
100
- "для точки нужны и lat, и lon параметр пропущен",
137
+ "точка в этой ссылке указана наполовину, поэтому она пропущена",
101
138
  "search.url.issue.bbox_malformed":
102
- "для области нужны четыре числа (minLat,minLon,maxLat,maxLon) параметр пропущен",
139
+ "область на карте в этой ссылке указана не полностью, поэтому она пропущена",
103
140
  "search.url.issue.range_malformed":
104
- "{param} не диапазон вида from..to, параметр пропущен",
141
+ "диапазону «{param}» в этой ссылке не хватает чисел, поэтому он пропущен",
105
142
 
106
143
  "search.degraded.title": "Что этот поиск не смог сделать",
107
144
  "search.degraded.typo_tolerance":
package/src/index.ts CHANGED
@@ -102,6 +102,24 @@ export type {
102
102
  FacetOption,
103
103
  } from "./state/facets.js";
104
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
+
105
123
  // ── model (runtime wiring, query keys, context, hooks) ───────────────────────
106
124
  export { createSearchRuntime } from "./model/runtime.js";
107
125
  export type {
@@ -117,7 +135,7 @@ export {
117
135
  export { searchQueryKeys } from "./model/queryKeys.js";
118
136
  export type { SearchQueryKeyParams } from "./model/queryKeys.js";
119
137
 
120
- export { useRankingDisclosure, useSearchQuery } from "./model/queries.js";
138
+ export { useRankingDisclosure, useSearchQuery, useSuggest } from "./model/queries.js";
121
139
 
122
140
  // ── headless (renderless components) ─────────────────────────────────────────
123
141
  export { SearchProvider } from "./headless/SearchProvider.js";
@@ -129,9 +147,11 @@ export type {
129
147
  } from "./headless/SearchStateProvider.js";
130
148
  export { SearchResults } from "./headless/SearchResults.js";
131
149
  export type { SearchPageInfo, SearchResultsBag } from "./headless/SearchResults.js";
132
- export { FacetPanel } from "./headless/FacetPanel.js";
150
+ export { FacetPanel, useFacetPanel } from "./headless/FacetPanel.js";
133
151
  export type { FacetPanelBag } from "./headless/FacetPanel.js";
134
152
  export { useAppliedSort } from "./headless/useAppliedSort.js";
153
+ export { useSearchBox } from "./headless/useSearchBox.js";
154
+ export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
135
155
  export { RankingDisclosure } from "./headless/RankingDisclosure.js";
136
156
  export type { RankingDisclosureBag } from "./headless/RankingDisclosure.js";
137
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
  });
@@ -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;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The RANGE half of the filter model — `r.<slug>=from..to`.
3
+ *
4
+ * The codec has carried ranges since 0.1 (`setRangeValue`, `RANGE_PREFIX`) and
5
+ * nothing ever drew one: a marketplace shipped without a price filter because
6
+ * the panel only knew how to draw checkboxes. This module is the missing half:
7
+ * which slugs a range row exists for, and what the row is called.
8
+ *
9
+ * ── Where a range row comes from, and why it is not the response ───────────
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.
16
+ *
17
+ * That last clause is the same rule `buildFacetGroups` follows for a filtered
18
+ * slug that fell out of the plan: a constraint that is ACTIVE must always have
19
+ * a control that removes it, even when the schema no longer explains it.
20
+ * Otherwise a shared link narrows the search with nothing on screen to widen it
21
+ * again.
22
+ *
23
+ * `date` is range-capable on the wire and is deliberately NOT here: a date
24
+ * range needs a date editor, the editor lives in `@stapel/attributes-react`'s
25
+ * registry, and a numeric input over a Unix timestamp would be worse than no
26
+ * control at all. The codec still round-trips one a host sets.
27
+ */
28
+ import { featureConfig, featureName, featureType } from "@stapel/attributes-react";
29
+ import type { FeatureDef } from "@stapel/attributes-react";
30
+ import type { SearchQueryState, SearchRange } from "../api/types.js";
31
+
32
+ /**
33
+ * Value types a numeric range row is drawn for (`config.type`, the
34
+ * stapel-attributes value-type slug).
35
+ */
36
+ export const RANGE_FEATURE_TYPES: readonly string[] = [
37
+ "int",
38
+ "float",
39
+ "convertible_unit",
40
+ ];
41
+
42
+ /** One `r.<slug>` row of the filter panel. */
43
+ export interface RangeGroup {
44
+ readonly slug: string;
45
+ /** The feature's display name (translated when it is a key), else the slug. */
46
+ readonly label: string;
47
+ /** The schema entry behind the slug, when the host supplied one. */
48
+ readonly feature: FeatureDef | undefined;
49
+ /** The applied bounds, exactly as the URL carries them (strings — the wire
50
+ * never promised a number, and re-formatting one would rewrite the link). */
51
+ readonly from: string | undefined;
52
+ readonly to: string | undefined;
53
+ /** Bounds the schema declares, for the input's own limits. */
54
+ readonly min: number | undefined;
55
+ readonly max: number | undefined;
56
+ /** Unit suffix the schema declares (`postfix`, or a convertible unit). */
57
+ readonly unit: string | undefined;
58
+ /** `1` for an integer feature — a whole-number input for a whole number. */
59
+ readonly step: number | undefined;
60
+ /** Whether the URL currently constrains this slug. */
61
+ readonly active: boolean;
62
+ }
63
+
64
+ export interface BuildRangeGroupsInput {
65
+ readonly state: SearchQueryState;
66
+ readonly categoryFeatures?: readonly FeatureDef[];
67
+ /** Translator for label keys (the schema's `name` is often one). */
68
+ readonly t?: (key: string) => string;
69
+ }
70
+
71
+ function num(value: unknown): number | undefined {
72
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
73
+ }
74
+
75
+ function str(value: unknown): string | undefined {
76
+ return typeof value === "string" && value.length > 0 ? value : undefined;
77
+ }
78
+
79
+ function translate(t: ((key: string) => string) | undefined, key: string): string {
80
+ if (t === undefined) return key;
81
+ const resolved = t(key);
82
+ return resolved.length > 0 ? resolved : key;
83
+ }
84
+
85
+ /** Is this feature one a numeric range row is drawn for? */
86
+ export function isRangeFeature(feature: FeatureDef): boolean {
87
+ const type = featureType(feature);
88
+ return type !== undefined && RANGE_FEATURE_TYPES.includes(type);
89
+ }
90
+
91
+ /**
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.
95
+ */
96
+ export function buildRangeGroups(
97
+ input: BuildRangeGroupsInput
98
+ ): readonly RangeGroup[] {
99
+ const bySlug = new Map<string, FeatureDef>();
100
+ for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
101
+
102
+ const slugs: string[] = [];
103
+ for (const feature of input.categoryFeatures ?? []) {
104
+ if (isRangeFeature(feature)) slugs.push(feature.slug);
105
+ }
106
+ for (const slug of Object.keys(input.state.ranges)) {
107
+ if (!slugs.includes(slug)) slugs.push(slug);
108
+ }
109
+
110
+ return slugs.map((slug) => {
111
+ const feature = bySlug.get(slug);
112
+ const config = feature === undefined ? {} : featureConfig(feature);
113
+ const applied: SearchRange | undefined = input.state.ranges[slug];
114
+ return {
115
+ slug,
116
+ label:
117
+ feature === undefined ? slug : translate(input.t, featureName(feature)),
118
+ feature,
119
+ from: applied?.from,
120
+ to: applied?.to,
121
+ min: num(config["min"]),
122
+ max: num(config["max"]),
123
+ unit: str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"]),
124
+ step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
125
+ active: applied !== undefined,
126
+ };
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Is a from/to pair one the server could ever match?
132
+ *
133
+ * `100..50` is syntactically fine and semantically empty, and the backend does
134
+ * not refuse it — it answers zero results, which reads as "there is nothing
135
+ * like this" rather than "you typed it backwards". So the panel refuses to
136
+ * apply it and says why, beside the button (`search.facets.range_invalid`).
137
+ */
138
+ export function isRangeUsable(range: SearchRange): boolean {
139
+ if (range.from === undefined || range.to === undefined) return true;
140
+ const from = Number(range.from);
141
+ const to = Number(range.to);
142
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return true;
143
+ return from <= to;
144
+ }
@@ -1,9 +0,0 @@
1
- import type { CSSProperties, ReactElement, ReactNode } from "react";
2
- import type { FlowErrorDisplay } from "@stapel/core";
3
- export declare function ErrorAlert(props: {
4
- error: FlowErrorDisplay | undefined;
5
- style?: CSSProperties | undefined;
6
- testId?: string | undefined;
7
- action?: ReactNode;
8
- }): ReactElement | null;
9
- //# sourceMappingURL=ErrorAlert.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ErrorAlert.d.ts","sourceRoot":"","sources":["../../src/default/ErrorAlert.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,wBAAgB,UAAU,CAAC,KAAK,EAAE;IAChC,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACpC,KAAK,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,GAAG,YAAY,GAAG,IAAI,CAsBtB"}
@@ -1,27 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * The one error surface this pair's default skin renders, so the split copy
4
- * core produces reaches a screen the same way everywhere (mirrors the
5
- * docs/notifications/workspaces etalon).
6
- *
7
- * Core's `describeFlowError` splits a failure into the sentence a person
8
- * reads and the technical detail a support agent quotes (owner report
9
- * 2026-08-09: the status used to be spliced into the sentence, ending in a
10
- * bare `" (500)"`). The split only helps if a skin renders both halves at
11
- * their own weights: `message` at normal weight, `detail` muted and small.
12
- *
13
- * `undefined` in — including a `detail` core left `undefined` because there
14
- * was nothing worth quoting — renders nothing rather than an empty line.
15
- */
16
- import { Alert, Typography } from "antd";
17
- export function ErrorAlert(props) {
18
- const { error } = props;
19
- if (!error)
20
- return null;
21
- return (_jsx(Alert, { type: "error", showIcon: true, ...(props.style ? { style: props.style } : {}), ...(props.testId ? { "data-testid": props.testId } : {}), ...(props.action ? { action: props.action } : {}), message: error.message, ...(error.detail
22
- ? {
23
- description: (_jsx(Typography.Text, { type: "secondary", style: { fontSize: 12 }, children: error.detail })),
24
- }
25
- : {}) }));
26
- }
27
- //# sourceMappingURL=ErrorAlert.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ErrorAlert.js","sourceRoot":"","sources":["../../src/default/ErrorAlert.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAIzC,MAAM,UAAU,UAAU,CAAC,KAK1B;IACC,MAAM,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IACxB,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,OAAO,CACL,KAAC,KAAK,IACJ,IAAI,EAAC,OAAO,EACZ,QAAQ,WACJ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC3C,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACrD,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAClD,OAAO,EAAE,KAAK,CAAC,OAAO,KAClB,CAAC,KAAK,CAAC,MAAM;YACf,CAAC,CAAC;gBACE,WAAW,EAAE,CACX,KAAC,UAAU,CAAC,IAAI,IAAC,IAAI,EAAC,WAAW,EAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,YACtD,KAAK,CAAC,MAAM,GACG,CACnB;aACF;YACH,CAAC,CAAC,EAAE,CAAC,GACP,CACH,CAAC;AACJ,CAAC"}
@@ -1,24 +0,0 @@
1
- import type { CSSProperties, ReactElement, ReactNode } from "react";
2
- import type { ThemeMode } from "@stapel/tokens-antd";
3
- export interface SearchSkinThemeProps {
4
- /**
5
- * Light or dark. Defaults to the mode the host's document declares
6
- * (`resolveThemeMode()`). Pass explicitly to pin a side.
7
- */
8
- readonly mode?: ThemeMode;
9
- /** Extra styles merged onto the themed root element (layout only — colors
10
- * come from the mode). */
11
- readonly style?: CSSProperties;
12
- readonly children: ReactNode;
13
- }
14
- /**
15
- * `<SearchSkinTheme/>` — the self-theming wrapper of
16
- * `@stapel/search-react/default`. Standalone use is supported (wrap any
17
- * composition of the skin's parts once); the shipped surfaces
18
- * (`SearchPage`, `SearchResultsPane`, `FacetPanelPane`,
19
- * `RankingDisclosurePane`) each already wrap themselves, and nested antd
20
- * `ConfigProvider`s merge, so composing them under one `SearchSkinTheme`
21
- * stays correct.
22
- */
23
- export declare function SearchSkinTheme(props: SearchSkinThemeProps): ReactElement;
24
- //# sourceMappingURL=theme.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/default/theme.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B;8BAC0B;IAC1B,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,YAAY,CAmBzE"}
@@ -1,39 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * The default skin's INTERNAL theme provider — every `/default` surface wraps
4
- * itself in this, so the skin is readable no matter what the host supplies
5
- * (tracker #26: a skin set with no internal provider once inherited a theme
6
- * bridge serving light-mode values inside a dark document — text on
7
- * background at 1.00:1). The theme derives from `@stapel/tokens` via
8
- * `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
9
- * the HOST's document declares (`resolveThemeMode()` — the `data-theme`
10
- * attribute `tokens.css` keys its dark block on), never to a hardcoded side.
11
- *
12
- * This is also what "override styles" means for this pair: a storefront
13
- * retheming through the §68 token JSON regenerates its `--stapel-*` custom
14
- * properties and the result page follows with zero code — a far better deal
15
- * than a prop-per-colour API, and the reason the skin has none.
16
- */
17
- import { useMemo } from "react";
18
- import { ConfigProvider } from "antd";
19
- import { resolveThemeMode, toAntdTheme, toAntdThemeConfig, } from "@stapel/tokens-antd";
20
- /**
21
- * `<SearchSkinTheme/>` — the self-theming wrapper of
22
- * `@stapel/search-react/default`. Standalone use is supported (wrap any
23
- * composition of the skin's parts once); the shipped surfaces
24
- * (`SearchPage`, `SearchResultsPane`, `FacetPanelPane`,
25
- * `RankingDisclosurePane`) each already wrap themselves, and nested antd
26
- * `ConfigProvider`s merge, so composing them under one `SearchSkinTheme`
27
- * stays correct.
28
- */
29
- export function SearchSkinTheme(props) {
30
- const mode = props.mode ?? resolveThemeMode();
31
- const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
32
- const token = useMemo(() => toAntdTheme(mode), [mode]);
33
- return (_jsx(ConfigProvider, { theme: theme, children: _jsx("div", { "data-search-skin-root": true, "data-search-skin-mode": mode, style: {
34
- color: token.colorText,
35
- backgroundColor: token.colorBgContainer,
36
- ...props.style,
37
- }, children: props.children }) }));
38
- }
39
- //# sourceMappingURL=theme.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/default/theme.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAEhC,OAAO,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AACtC,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAe7B;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,KAA2B;IACzD,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,gBAAgB,EAAE,CAAC;IAC9C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7D,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IACvD,OAAO,CACL,KAAC,cAAc,IAAC,KAAK,EAAE,KAAK,YAC1B,sEAEyB,IAAI,EAC3B,KAAK,EAAE;gBACL,KAAK,EAAE,KAAK,CAAC,SAAS;gBACtB,eAAe,EAAE,KAAK,CAAC,gBAAgB;gBACvC,GAAG,KAAK,CAAC,KAAK;aACf,YAEA,KAAK,CAAC,QAAQ,GACX,GACS,CAClB,CAAC;AACJ,CAAC"}