@stapel/search-react 0.23.0 → 0.25.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 (55) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +76 -2
  3. package/dist/default/FilterChips.d.ts +86 -2
  4. package/dist/default/FilterChips.d.ts.map +1 -1
  5. package/dist/default/FilterChips.js +208 -2
  6. package/dist/default/FilterChips.js.map +1 -1
  7. package/dist/default/OtherCategoriesLine.d.ts +49 -0
  8. package/dist/default/OtherCategoriesLine.d.ts.map +1 -0
  9. package/dist/default/OtherCategoriesLine.js +127 -0
  10. package/dist/default/OtherCategoriesLine.js.map +1 -0
  11. package/dist/default/SearchPage.d.ts +36 -1
  12. package/dist/default/SearchPage.d.ts.map +1 -1
  13. package/dist/default/SearchPage.js +9 -4
  14. package/dist/default/SearchPage.js.map +1 -1
  15. package/dist/default/SearchResultsPane.d.ts +15 -0
  16. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  17. package/dist/default/SearchResultsPane.js +4 -1
  18. package/dist/default/SearchResultsPane.js.map +1 -1
  19. package/dist/default/index.d.ts +4 -2
  20. package/dist/default/index.d.ts.map +1 -1
  21. package/dist/default/index.js +2 -1
  22. package/dist/default/index.js.map +1 -1
  23. package/dist/headless/useOtherCategories.d.ts +52 -0
  24. package/dist/headless/useOtherCategories.d.ts.map +1 -0
  25. package/dist/headless/useOtherCategories.js +92 -0
  26. package/dist/headless/useOtherCategories.js.map +1 -0
  27. package/dist/i18n/es.d.ts.map +1 -1
  28. package/dist/i18n/es.js +9 -0
  29. package/dist/i18n/es.js.map +1 -1
  30. package/dist/i18n/keys.d.ts +42 -0
  31. package/dist/i18n/keys.d.ts.map +1 -1
  32. package/dist/i18n/keys.js +53 -0
  33. package/dist/i18n/keys.js.map +1 -1
  34. package/dist/i18n/ru.d.ts.map +1 -1
  35. package/dist/i18n/ru.js +10 -0
  36. package/dist/i18n/ru.js.map +1 -1
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/llms.txt +2 -1
  42. package/manifest.json +34 -1
  43. package/nav-manifest.json +1 -1
  44. package/package.json +7 -7
  45. package/src/analytics/generated/events.json +1 -1
  46. package/src/default/FilterChips.tsx +308 -4
  47. package/src/default/OtherCategoriesLine.tsx +238 -0
  48. package/src/default/SearchPage.tsx +65 -1
  49. package/src/default/SearchResultsPane.tsx +28 -0
  50. package/src/default/index.ts +28 -1
  51. package/src/headless/useOtherCategories.ts +193 -0
  52. package/src/i18n/es.ts +10 -0
  53. package/src/i18n/keys.ts +57 -0
  54. package/src/i18n/ru.ts +11 -0
  55. package/src/index.ts +10 -0
@@ -86,6 +86,7 @@ import { FilterChips } from "./FilterChips.js";
86
86
  import { LocationSummaryLine } from "./LocationSummaryLine.js";
87
87
  import { PageSizeSelect } from "./PageSizeSelect.js";
88
88
  import { SearchBox } from "./SearchBox.js";
89
+ import type { OtherCategoryNamer } from "./OtherCategoriesLine.js";
89
90
  import { SearchResultsPane } from "./SearchResultsPane.js";
90
91
  import type { SearchResultsWrapper } from "./SearchResultsPane.js";
91
92
  import { SortSelect } from "./SortSelect.js";
@@ -240,7 +241,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
240
241
  * What the current category is CALLED — the chip's own text. The pair holds
241
242
  * a path of slugs and no way to turn one into a catalogue name; absent, the
242
243
  * chip states the path's last segment. See
243
- * {@link FilterChipsProps.categoryLabel}.
244
+ * {@link FilterChipsOpenerProps.categoryLabel}.
244
245
  */
245
246
  readonly categoryLabel?: ReactNode;
246
247
  /** The location control slot (`geo-react`). */
@@ -305,6 +306,40 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
305
306
  * describes the whole page, not the results column of it.
306
307
  */
307
308
  readonly resultsHeader?: ReactNode;
309
+ /**
310
+ * Draw the APPLIED filter row in the results header — one chip per applied
311
+ * value and per applied range, each of which removes it
312
+ * (`<FilterChips mode="applied">`).
313
+ *
314
+ * `"desktop"` is the shape this exists for: where the rail is on screen a
315
+ * choice otherwise leaves no trace above the results and dropping one of
316
+ * two constraints means hunting its button back down the column, while on
317
+ * the phone the opener row below already states every applied filter on its
318
+ * own chips. `true` draws it in both layouts; omitted, nothing changes.
319
+ *
320
+ * It renders itself away when nothing is applied, so a host never has to
321
+ * ask.
322
+ */
323
+ readonly appliedChips?: boolean | "desktop";
324
+ /**
325
+ * Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
326
+ * results — one line, from the SAME response the cards came from.
327
+ *
328
+ * It replaces the shape a storefront had built by hand: a full-width block
329
+ * of one row per category, fetched from `/suggest` after the page had
330
+ * settled and pushing everything below it when it landed. Here the rows are
331
+ * `facet_meta.categories`, which the answer already carried, so with results
332
+ * on screen the line costs no request and cannot arrive late. Only an EMPTY
333
+ * result set asks `/suggest`, into a slot whose height is reserved from the
334
+ * first frame.
335
+ *
336
+ * Opt-in, and `categoryName` is what makes it useful: the pair holds id
337
+ * paths and no catalogue — see {@link OtherCategoriesLineProps.categoryName}.
338
+ */
339
+ readonly otherCategories?: boolean;
340
+ /** What a category id path is CALLED, for the line above. The same question
341
+ * `categoryLabel` answers for the chip, asked once per row. */
342
+ readonly categoryName?: OtherCategoryNamer;
308
343
  /** What this surface calls its result list. See
309
344
  * {@link SearchResultsPaneProps.heading}. */
310
345
  readonly resultsHeading?: ReactNode;
@@ -401,6 +436,9 @@ interface SearchPageBodyProps {
401
436
  readonly footer?: ReactNode;
402
437
  readonly filtersHeader?: ReactNode;
403
438
  readonly resultsHeader?: ReactNode;
439
+ readonly appliedChips?: boolean | "desktop";
440
+ readonly otherCategories?: boolean;
441
+ readonly categoryName?: OtherCategoryNamer;
404
442
  readonly resultsHeading?: ReactNode;
405
443
  readonly degradationNotice?: DegradationNoticeVariant;
406
444
  readonly filtersLayout?: SearchFiltersLayout;
@@ -570,6 +608,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
570
608
  {...(view.layout !== undefined ? { layout: view.layout } : {})}
571
609
  {...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
572
610
  {...(props.footer !== undefined ? { footer: props.footer } : {})}
611
+ {...(props.otherCategories !== undefined
612
+ ? { otherCategories: props.otherCategories }
613
+ : {})}
614
+ {...(props.categoryName !== undefined
615
+ ? { categoryName: props.categoryName }
616
+ : {})}
573
617
  {...(props.resultsHeading !== undefined
574
618
  ? { heading: props.resultsHeading }
575
619
  : {})}
@@ -637,6 +681,20 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
637
681
  <div data-testid="search-results-header">{props.resultsHeader}</div>
638
682
  )}
639
683
 
684
+ {/* What the search is NARROWED to, above the results, each constraint
685
+ beside the control that drops it. Drawn in the same band as the
686
+ results header because that is where a host would otherwise hand-mount
687
+ it — and it draws nothing at all when nothing is applied. */}
688
+ {(props.appliedChips === true ||
689
+ (props.appliedChips === "desktop" && layout !== "sheet")) && (
690
+ <FilterChips
691
+ mode="applied"
692
+ {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
693
+ {...(locale !== undefined ? { locale } : {})}
694
+ {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
695
+ />
696
+ )}
697
+
640
698
  {showFilters && layout === "sheet" ? (
641
699
  <>
642
700
  {/* The phone's filter row. It REPLACES the full-width "Filters (3)"
@@ -732,6 +790,9 @@ export function SearchPage(props: SearchPageProps): ReactElement {
732
790
  footer,
733
791
  filtersHeader,
734
792
  resultsHeader,
793
+ appliedChips,
794
+ otherCategories,
795
+ categoryName,
735
796
  resultsHeading,
736
797
  degradationNotice,
737
798
  filtersLayout,
@@ -766,6 +827,9 @@ export function SearchPage(props: SearchPageProps): ReactElement {
766
827
  {...(footer !== undefined ? { footer } : {})}
767
828
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
768
829
  {...(resultsHeader !== undefined ? { resultsHeader } : {})}
830
+ {...(appliedChips !== undefined ? { appliedChips } : {})}
831
+ {...(otherCategories !== undefined ? { otherCategories } : {})}
832
+ {...(categoryName !== undefined ? { categoryName } : {})}
769
833
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
770
834
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
771
835
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
@@ -46,6 +46,8 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
46
46
  import { DegradationNotice } from "./DegradationNotice.js";
47
47
  import { EmptyExits } from "./EmptyExits.js";
48
48
  import type { DegradationNoticeVariant } from "./DegradationNotice.js";
49
+ import { OtherCategoriesLine } from "./OtherCategoriesLine.js";
50
+ import type { OtherCategoryNamer } from "./OtherCategoriesLine.js";
49
51
  import { SearchResultCard } from "./SearchResultCard.js";
50
52
  import type { SearchCardRenderer } from "./SearchResultCard.js";
51
53
  import type { SearchResultsLayout } from "./ViewSwitch.js";
@@ -261,6 +263,20 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
261
263
  * this pair offers the slot and never the tree.
262
264
  */
263
265
  readonly renderEmptyExits?: () => ReactNode;
266
+ /**
267
+ * Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
268
+ * results — the sections THIS answer is made of, one line, from the same
269
+ * response (`<OtherCategoriesLine>`).
270
+ *
271
+ * Opt-in, because the rows are id PATHS and naming them is the host's
272
+ * (`categoryName`): a deployment that passes neither gets the line only for
273
+ * the paths the server itself named, which on a slug-less catalogue is
274
+ * none. Where it is on, it costs no request while there are results.
275
+ */
276
+ readonly otherCategories?: boolean;
277
+ /** What a category id path is CALLED, for the line above — see
278
+ * {@link OtherCategoriesLineProps.categoryName}. */
279
+ readonly categoryName?: OtherCategoryNamer;
264
280
  }
265
281
 
266
282
  function Count(props: { bag: SearchResultsBag }): ReactElement | null {
@@ -375,6 +391,18 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
375
391
  scorerName={scorerName}
376
392
  />
377
393
 
394
+ {/* ABOVE the results and in the SAME frame as them. Under them
395
+ and asynchronously is where it was, and both halves of that
396
+ pushed a page a person had started reading. */}
397
+ {props.otherCategories === true && (
398
+ <OtherCategoriesLine
399
+ {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
400
+ {...(props.categoryName !== undefined
401
+ ? { categoryName: props.categoryName }
402
+ : {})}
403
+ />
404
+ )}
405
+
378
406
  <LoadList
379
407
  state={bag.state}
380
408
  testId="search"
@@ -48,6 +48,19 @@ export {
48
48
  } from "./SearchPage.js";
49
49
  export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
50
50
 
51
+ export {
52
+ OtherCategoriesLine,
53
+ OTHER_CATEGORIES_CLASS,
54
+ OTHER_CATEGORIES_PHONE_ROWS,
55
+ OTHER_CATEGORIES_SLOT_MIN_HEIGHT,
56
+ OTHER_CATEGORIES_STYLE_HREF,
57
+ otherCategoriesCss,
58
+ } from "./OtherCategoriesLine.js";
59
+ export type {
60
+ OtherCategoriesLineProps,
61
+ OtherCategoryNamer,
62
+ } from "./OtherCategoriesLine.js";
63
+
51
64
  export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
52
65
  export type {
53
66
  SearchResultsPaneProps,
@@ -61,12 +74,26 @@ export {
61
74
  CHIP_ROW_CAP,
62
75
  CHIP_ROW_CLASS,
63
76
  CHIP_ROW_STYLE_HREF,
77
+ appliedChipTestId,
78
+ buildAppliedChips,
64
79
  capChipRow,
65
80
  categoryLeaf,
66
81
  chipRowCss,
67
82
  orderChipFilters,
83
+ rangeChipText,
84
+ rangeLabelSource,
85
+ } from "./FilterChips.js";
86
+ export type {
87
+ AppliedChip,
88
+ AppliedChipTarget,
89
+ ChipBand,
90
+ ChipSpec,
91
+ FilterChipsAppliedProps,
92
+ FilterChipsCommonProps,
93
+ FilterChipsMode,
94
+ FilterChipsOpenerProps,
95
+ FilterChipsProps,
68
96
  } from "./FilterChips.js";
69
- export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
70
97
  export { EmptyExits, RADIUS_WIDEN_FACTOR, parentCategory } from "./EmptyExits.js";
71
98
  export type { EmptyExitsProps } from "./EmptyExits.js";
72
99
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * "The same query, in another section" — the categories THIS answer is made
3
+ * of, read out of the answer that drew the cards.
4
+ *
5
+ * ## What it replaces
6
+ *
7
+ * A storefront drew this as a full-width block under the results: one row per
8
+ * category, fetched from `/suggest` AFTER the page had settled, so a tall
9
+ * panel appeared a beat late and pushed everything a person was already
10
+ * reading. Both halves were avoidable. The block was tall where one line
11
+ * says the same thing, and the request was a second ask for something the
12
+ * page already had: `/query` answers with `facet_meta.categories` — `{path,
13
+ * count}` for every category the candidate set contains — and that is
14
+ * exactly the list the block was printing.
15
+ *
16
+ * So the rows come from the SEARCH response. No request of this hook's own
17
+ * while there are results, and therefore nothing to arrive late.
18
+ *
19
+ * ## The one case that DOES earn a request
20
+ *
21
+ * An empty result set has no candidates, so `facet_meta.categories` is empty
22
+ * too — and that is precisely the screen where "this word exists in these
23
+ * sections" is worth the most. There, and only there, `/suggest` is asked.
24
+ * The bag says so through {@link OtherCategoriesBag.reserving}, so a surface
25
+ * can hold the row's height from the first frame instead of letting the
26
+ * answer push the page a second time.
27
+ *
28
+ * ## Names come from the cache, not from a request
29
+ *
30
+ * `facet_meta.categories` carries id PATHS and counts, never names — the
31
+ * catalogue belongs to `categories-react`. Three sources are tried, in order:
32
+ *
33
+ * 1. the host's resolver (a surface prop), which is the only one that can
34
+ * name an id path with certainty;
35
+ * 2. the `/suggest` answer ALREADY IN THE QUERY CACHE — the type-ahead asked
36
+ * about this same word a keystroke earlier, and its rows carry the
37
+ * server's own names. `useSuggest` with `enabled: false` hands back a
38
+ * cached answer and fires nothing, so this costs no request;
39
+ * 3. the path's last segment, when it is a slug rather than a number.
40
+ *
41
+ * A row none of the three can name is DROPPED rather than printed as "163" —
42
+ * the same rule `categoryLeaf` states for the category chip.
43
+ */
44
+ import type { SearchResponse, SuggestCategory } from "../api/types.js";
45
+ import { useSearchQuery, useSuggest } from "../model/queries.js";
46
+ import { offerableCategories } from "./useSearchBox.js";
47
+ import { useSearchState } from "./SearchStateProvider.js";
48
+
49
+ /** How many entries the line prints before it folds the rest. */
50
+ export const OTHER_CATEGORIES_LIMIT = 8;
51
+
52
+ /**
53
+ * The phone's cap.
54
+ *
55
+ * Half the desktop's, because the requirement is a LINE and at 390px eight
56
+ * entries are not one — they are the block this replaces, wearing a comma.
57
+ * Four short section names plus the fold marker are the two rows a phone can
58
+ * spend on a navigation aid above a list of cards.
59
+ */
60
+ export const OTHER_CATEGORIES_PHONE_LIMIT = 4;
61
+
62
+ /** One category the current answer reached, with the count that answer gives it. */
63
+ export interface OtherCategoryRow {
64
+ /** The slash-joined id path — the same string the `category` filter takes,
65
+ * so narrowing to it needs no translation and no tree. */
66
+ readonly category: string;
67
+ /** Documents matching THIS query that live there. Scoped to the query, which
68
+ * is why narrowing the search is the honest destination and the bare
69
+ * category feed is not: the feed would show a different number. */
70
+ readonly count: number;
71
+ /** The server's own name, when this row came from `/suggest` or when a
72
+ * cached suggest answer named the same path. */
73
+ readonly name?: string;
74
+ /** Display names root→leaf, when the server sent them. */
75
+ readonly path?: readonly string[];
76
+ }
77
+
78
+ export interface OtherCategoriesBag {
79
+ /** Every row, uncapped and in the order the line should print them. Capping
80
+ * is a decision about the SURFACE (a phone takes fewer), so it is not made
81
+ * here. */
82
+ readonly rows: readonly OtherCategoryRow[];
83
+ /**
84
+ * Where the rows came from. `"results"` — the search response itself, drawn
85
+ * in the same frame as the cards. `"suggest"` — the empty-result path, the
86
+ * only one with a request behind it. `"none"` — nothing to offer.
87
+ */
88
+ readonly source: "results" | "suggest" | "none";
89
+ /**
90
+ * The empty-result path is active, so a row is coming from a request that
91
+ * has not landed (or landed empty). A surface keeps the height either way:
92
+ * space reserved from the first frame is space nothing can push.
93
+ */
94
+ readonly reserving: boolean;
95
+ /** That request is in flight right now. */
96
+ readonly pending: boolean;
97
+ }
98
+
99
+ const NO_ROWS: readonly OtherCategoryRow[] = [];
100
+
101
+ /** The path's last segment, when it names something — never a bare id. */
102
+ export function otherCategoryLeaf(category: string): string | undefined {
103
+ const parts = category.split("/").filter((part) => part.length > 0);
104
+ const leaf = parts[parts.length - 1];
105
+ if (leaf === undefined || /^\d+$/.test(leaf)) return undefined;
106
+ return leaf;
107
+ }
108
+
109
+ /** The rows the ANSWER carries, busiest first and without the section the
110
+ * search is already in. */
111
+ function rowsFromResponse(
112
+ answer: SearchResponse | undefined,
113
+ applied: string | undefined,
114
+ named: ReadonlyMap<string, SuggestCategory>
115
+ ): readonly OtherCategoryRow[] {
116
+ const meta = answer?.facet_meta.categories ?? [];
117
+ const rows = meta
118
+ // "Other" than the one the search is in. Descendants stay: narrowing from
119
+ // a branch to one of its leaves is a different section and the move this
120
+ // line exists for.
121
+ .filter((row) => row.category !== applied)
122
+ .map((row) => {
123
+ const match = named.get(row.category);
124
+ return {
125
+ category: row.category,
126
+ count: row.count,
127
+ ...(match !== undefined ? { name: match.name, path: match.path } : {}),
128
+ };
129
+ });
130
+ // The server documents "busiest first" and the cap has to take the busiest
131
+ // eight; asserting the order here is cheaper than trusting it and being
132
+ // wrong about which eight a person sees.
133
+ return [...rows].sort((a, b) =>
134
+ b.count === a.count ? a.category.localeCompare(b.category) : b.count - a.count
135
+ );
136
+ }
137
+
138
+ export function useOtherCategories(
139
+ options: { readonly enabled?: boolean } = {}
140
+ ): OtherCategoriesBag {
141
+ const { state } = useSearchState();
142
+ const enabled = options.enabled ?? true;
143
+
144
+ // The SAME query the results pane runs: identical key, identical cache
145
+ // entry, no second request. This hook can therefore be mounted anywhere
146
+ // under the provider without the envelope having to be threaded to it.
147
+ const query = useSearchQuery(state, { enabled });
148
+ const answer = query.data;
149
+
150
+ // Ready AND empty. `answer === undefined` is "still loading", which is not
151
+ // an empty result and must not spend a request.
152
+ const resultsEmpty = enabled && answer !== undefined && answer.items.length === 0;
153
+
154
+ // `enabled: false` still returns whatever the box already put in the cache
155
+ // and asks nothing — which is how a page WITH results gets the server's
156
+ // names for free. A page WITHOUT results turns the same hook into the one
157
+ // request this feature ever makes.
158
+ const suggest = useSuggest({ type: state.type, q: state.q, enabled: resultsEmpty });
159
+ // Through the pair's own reader, which is where "this server sent no
160
+ // categories key at all" is already distinguished from "nothing matched".
161
+ const suggested = offerableCategories(suggest.data);
162
+ const named = new Map(suggested.map((row) => [row.category, row]));
163
+
164
+ const fromResults = rowsFromResponse(answer, state.category, named);
165
+
166
+ const pending = resultsEmpty && suggest.isLoading && suggest.fetchStatus === "fetching";
167
+
168
+ if (fromResults.length > 0) {
169
+ return { rows: fromResults, source: "results", reserving: false, pending: false };
170
+ }
171
+
172
+ if (resultsEmpty) {
173
+ // The server's rank order is kept as sent: `/suggest` ranks by match grade
174
+ // and stock before count, and re-sorting by count here would throw that
175
+ // away for a number that is not what ranked them.
176
+ const rows = suggested
177
+ .filter((row) => row.category !== state.category)
178
+ .map((row) => ({
179
+ category: row.category,
180
+ count: row.count,
181
+ name: row.name,
182
+ path: row.path,
183
+ }));
184
+ return {
185
+ rows,
186
+ source: rows.length > 0 ? "suggest" : "none",
187
+ reserving: true,
188
+ pending,
189
+ };
190
+ }
191
+
192
+ return { rows: NO_ROWS, source: "none", reserving: false, pending: false };
193
+ }
package/src/i18n/es.ts CHANGED
@@ -119,12 +119,22 @@ export const searchI18nBundleEs: I18nDictionary = {
119
119
  "search.filters.short": "Filtros",
120
120
  "search.filters.chip_more": ", +{count}",
121
121
  "search.filters.chips_overflow": "Más · {count}",
122
+ "search.filters.applied_label": "Filtros aplicados",
123
+ "search.filters.chip_pair": "{name}: {value}",
124
+ "search.filters.chip_remove": "Quitar el filtro {filter}",
125
+ "search.filters.chip_range_between": "de {from} a {to}",
126
+ "search.filters.chip_range_from": "desde {from}",
127
+ "search.filters.chip_range_to": "hasta {to}",
122
128
 
123
129
  "search.empty.exits_title": "Prueba a ampliar la búsqueda",
124
130
  "search.empty.up_a_level": "Subir un nivel",
125
131
  "search.empty.widen_radius": "Buscar en un radio de {km} km",
126
132
  "search.empty.anywhere": "Buscar en todas partes",
127
133
  "search.empty.drop_filter": "Sin «{name}»",
134
+
135
+ "search.other_categories.label": "Buscar en otras categorías:",
136
+ "search.other_categories.narrow": "Buscar en {name}",
137
+ "search.other_categories.more": "{count} más",
128
138
  "search.category.title": "Categoría",
129
139
  "search.category.clear": "Buscar en todo el catálogo",
130
140
  "search.category.current": "Buscando dentro de {path}",
package/src/i18n/keys.ts CHANGED
@@ -232,6 +232,25 @@ export const SEARCH_I18N_KEYS = {
232
232
  * `{count}` is how many chips it hides (D16). */
233
233
  filtersChipOverflow: "search.filters.chips_overflow",
234
234
 
235
+ // ── the APPLIED row (`<FilterChips mode="applied">`) ─────────────────────
236
+ /** The accessible name of the applied row. Distinct from
237
+ * `filtersChipsLabel`: that row OPENS pickers, this one DROPS filters, and
238
+ * a screen reader announcing both as "Filters" states neither. */
239
+ filtersAppliedLabel: "search.filters.applied_label",
240
+ /** One applied chip's caption: the axis AND the value — "Brand: Bosch".
241
+ * A chip printing the value alone states a constraint without saying what
242
+ * it constrains, which beside a rail of a dozen axes names nothing. */
243
+ filtersChipPair: "search.filters.chip_pair",
244
+ /** The accessible name of an applied chip: pressing it REMOVES the filter,
245
+ * and the caption alone never says so. `{filter}` is the caption. */
246
+ filtersChipRemove: "search.filters.chip_remove",
247
+ /** A numeric constraint with both bounds, as one phrase. */
248
+ filtersChipRangeBetween: "search.filters.chip_range_between",
249
+ /** A lower bound alone. */
250
+ filtersChipRangeFrom: "search.filters.chip_range_from",
251
+ /** An upper bound alone. */
252
+ filtersChipRangeTo: "search.filters.chip_range_to",
253
+
235
254
  // ── a partition: one template split by a value its children name ─────────
236
255
  /**
237
256
  * The first chip of a partition row — the parent itself, unnarrowed.
@@ -263,6 +282,33 @@ export const SEARCH_I18N_KEYS = {
263
282
  /** "Without {name}" — one applied filter, named as its own chip names it. */
264
283
  emptyDropFilter: "search.empty.drop_filter",
265
284
 
285
+ // ── the same query, in another section (one line, from the answer) ───────
286
+ /**
287
+ * The line's lead-in — "Search in other categories:". It ends in a colon
288
+ * because what follows is a list on the same line, and a translator who is
289
+ * shown the string without one has no way to know that.
290
+ *
291
+ * Not `search.box.categories` ("Sections", the type-ahead's group heading):
292
+ * that one titles a menu of destinations while a person types, this one
293
+ * introduces a row of narrowings beside a result list. Same noun, two
294
+ * surfaces, and the languages that decline it need both.
295
+ */
296
+ otherCategoriesLabel: "search.other_categories.label",
297
+ /**
298
+ * The accessible name of one entry. The visible caption is "Cars 12" — a
299
+ * name and a number, which says nothing about what pressing it DOES. This
300
+ * does: it narrows the search that is already on screen, and it keeps the
301
+ * query, which is why the number beside it stays true afterwards.
302
+ */
303
+ otherCategoriesNarrow: "search.other_categories.narrow",
304
+ /**
305
+ * The fold at the end of the line: how many sections it did not print.
306
+ * `{count}` is a bare number after a word, not a counted noun in a
307
+ * sentence, so this is not a plural family — the same shape as
308
+ * `facetsAllFilters` and `filtersChipOverflow`.
309
+ */
310
+ otherCategoriesMore: "search.other_categories.more",
311
+
266
312
  // ── category (a host slot, plus the control that removes it) ─────────────
267
313
  categoryTitle: "search.category.title",
268
314
  categoryClear: "search.category.clear",
@@ -542,12 +588,23 @@ export const searchI18nBundleEn: Record<string, string> = {
542
588
  "search.filters.short": "Filters",
543
589
  "search.filters.chip_more": ", +{count}",
544
590
  "search.filters.chips_overflow": "More · {count}",
591
+ "search.filters.applied_label": "Applied filters",
592
+ "search.filters.chip_pair": "{name}: {value}",
593
+ "search.filters.chip_remove": "Remove filter {filter}",
594
+ "search.filters.chip_range_between": "from {from} to {to}",
595
+ "search.filters.chip_range_from": "from {from}",
596
+ "search.filters.chip_range_to": "up to {to}",
545
597
 
546
598
  "search.empty.exits_title": "Try widening the search",
547
599
  "search.empty.up_a_level": "Go up a level",
548
600
  "search.empty.widen_radius": "Search within {km} km",
549
601
  "search.empty.anywhere": "Search anywhere",
550
602
  "search.empty.drop_filter": "Without {name}",
603
+
604
+ "search.other_categories.label": "Search in other categories:",
605
+ "search.other_categories.narrow": "Search in {name}",
606
+ "search.other_categories.more": "{count} more",
607
+
551
608
  "search.category.title": "Category",
552
609
  "search.category.clear": "Search the whole catalogue",
553
610
  "search.category.current": "Searching inside {path}",
package/src/i18n/ru.ts CHANGED
@@ -154,12 +154,23 @@ export const searchI18nBundleRu: I18nDictionary = {
154
154
  "search.filters.short": "Фильтры",
155
155
  "search.filters.chip_more": ", +{count}",
156
156
  "search.filters.chips_overflow": "Ещё {count}",
157
+ "search.filters.applied_label": "Выбранные фильтры",
158
+ "search.filters.chip_pair": "{name}: {value}",
159
+ "search.filters.chip_remove": "Убрать фильтр «{filter}»",
160
+ "search.filters.chip_range_between": "от {from} до {to}",
161
+ "search.filters.chip_range_from": "от {from}",
162
+ "search.filters.chip_range_to": "до {to}",
157
163
 
158
164
  "search.empty.exits_title": "Попробуйте расширить поиск",
159
165
  "search.empty.up_a_level": "Подняться на уровень выше",
160
166
  "search.empty.widen_radius": "Искать в радиусе {km} км",
161
167
  "search.empty.anywhere": "Искать везде",
162
168
  "search.empty.drop_filter": "Без «{name}»",
169
+
170
+ // One line, out of the same answer the cards came from: section and count.
171
+ "search.other_categories.label": "Поиск в других категориях:",
172
+ "search.other_categories.narrow": "Искать в разделе «{name}»",
173
+ "search.other_categories.more": "ещё {count}",
163
174
  "search.category.title": "Категория",
164
175
  "search.category.clear": "Искать по всему каталогу",
165
176
  "search.category.current": "Ищем внутри {path}",
package/src/index.ts CHANGED
@@ -194,6 +194,16 @@ export type {
194
194
  } from "./headless/useFacetLabels.js";
195
195
  export { useAppliedSort } from "./headless/useAppliedSort.js";
196
196
  export { offerableCategories, useSearchBox } from "./headless/useSearchBox.js";
197
+ export {
198
+ OTHER_CATEGORIES_LIMIT,
199
+ OTHER_CATEGORIES_PHONE_LIMIT,
200
+ otherCategoryLeaf,
201
+ useOtherCategories,
202
+ } from "./headless/useOtherCategories.js";
203
+ export type {
204
+ OtherCategoriesBag,
205
+ OtherCategoryRow,
206
+ } from "./headless/useOtherCategories.js";
197
207
  export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
198
208
  export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
199
209
  export type {