@stapel/search-react 0.9.1 → 0.10.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 (58) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/default/FilterChips.d.ts.map +1 -1
  3. package/dist/default/FilterChips.js +8 -23
  4. package/dist/default/FilterChips.js.map +1 -1
  5. package/dist/default/LocationSummaryLine.d.ts +24 -0
  6. package/dist/default/LocationSummaryLine.d.ts.map +1 -0
  7. package/dist/default/LocationSummaryLine.js +83 -0
  8. package/dist/default/LocationSummaryLine.js.map +1 -0
  9. package/dist/default/SearchPage.d.ts +19 -0
  10. package/dist/default/SearchPage.d.ts.map +1 -1
  11. package/dist/default/SearchPage.js +6 -6
  12. package/dist/default/SearchPage.js.map +1 -1
  13. package/dist/default/geoSheet.d.ts +61 -0
  14. package/dist/default/geoSheet.d.ts.map +1 -0
  15. package/dist/default/geoSheet.js +63 -0
  16. package/dist/default/geoSheet.js.map +1 -0
  17. package/dist/default/index.d.ts +2 -0
  18. package/dist/default/index.d.ts.map +1 -1
  19. package/dist/default/index.js +1 -0
  20. package/dist/default/index.js.map +1 -1
  21. package/dist/headless/useSearchCount.d.ts +44 -0
  22. package/dist/headless/useSearchCount.d.ts.map +1 -0
  23. package/dist/headless/useSearchCount.js +121 -0
  24. package/dist/headless/useSearchCount.js.map +1 -0
  25. package/dist/i18n/es.d.ts.map +1 -1
  26. package/dist/i18n/es.js +1 -0
  27. package/dist/i18n/es.js.map +1 -1
  28. package/dist/i18n/keys.d.ts +10 -0
  29. package/dist/i18n/keys.d.ts.map +1 -1
  30. package/dist/i18n/keys.js +11 -0
  31. package/dist/i18n/keys.js.map +1 -1
  32. package/dist/i18n/ru.d.ts.map +1 -1
  33. package/dist/i18n/ru.js +1 -0
  34. package/dist/i18n/ru.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/state/limits.d.ts +21 -0
  40. package/dist/state/limits.d.ts.map +1 -1
  41. package/dist/state/limits.js +21 -0
  42. package/dist/state/limits.js.map +1 -1
  43. package/llms.txt +2 -1
  44. package/manifest.json +30 -2
  45. package/nav-manifest.json +1 -1
  46. package/package.json +4 -4
  47. package/src/analytics/generated/events.json +1 -1
  48. package/src/default/FilterChips.tsx +17 -56
  49. package/src/default/LocationSummaryLine.tsx +187 -0
  50. package/src/default/SearchPage.tsx +33 -3
  51. package/src/default/geoSheet.tsx +161 -0
  52. package/src/default/index.ts +3 -0
  53. package/src/headless/useSearchCount.ts +189 -0
  54. package/src/i18n/es.ts +1 -0
  55. package/src/i18n/keys.ts +11 -0
  56. package/src/i18n/ru.ts +1 -0
  57. package/src/index.ts +8 -0
  58. package/src/state/limits.ts +23 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `useSearchCount(state)` — how many results a search state WOULD return,
3
+ * without showing any of them.
4
+ *
5
+ * A quick-search panel's button ("Show 128 listings") has to know the total
6
+ * for a state that is not on screen and not in the URL: the person is still
7
+ * composing it. `<SearchResults>` cannot answer that — it reads the committed
8
+ * URL state — and `useAppliedCount` deliberately reads the page already in
9
+ * cache. So this is a read of its own, over a state the caller hands in.
10
+ *
11
+ * ── THE GAP THIS HOOK RIDES OVER, STATED ──────────────────────────────────
12
+ *
13
+ * **stapel-search has no count-only endpoint.** `SearchApi` is `query`,
14
+ * `suggest` and `ranking`; nothing answers "how many" without also assembling
15
+ * a page. So this hook issues the ORDINARY query with the smallest page the
16
+ * endpoint will serve ({@link SEARCH_COUNT_PAGE_SIZE}) and facet counting
17
+ * switched off, and reads `count` out of the envelope it gets back. That is a
18
+ * deliberate choice with a cost: the engine still ranks the candidate set and
19
+ * the backend still serializes one row, so a count is roughly as expensive as
20
+ * a search. It is not a bug to be worked around client-side — a debounce is
21
+ * the mitigation, not a fix.
22
+ *
23
+ * FOLLOW-UP (stapel-search): a `GET /count` verb that answers the envelope's
24
+ * three count fields (`count`, `count_is_lower_bound`, `exact_total`) plus
25
+ * `degraded[]` and nothing else. When it lands, this hook's body changes and
26
+ * its signature does not.
27
+ *
28
+ * ── What it asks, and what it deliberately does not ───────────────────────
29
+ *
30
+ * The state is normalized before it goes out, and each removal is a rule:
31
+ *
32
+ * - `facets: "off"` — a count does not draw a facet panel, and counting
33
+ * facets is the expensive half of the request.
34
+ * - `limit: 1` — the smallest page. Zero is not a page size the endpoint
35
+ * documents, and asking for the default 24 would serialize 24 cards nobody
36
+ * renders.
37
+ * - `anchor` / `direction` dropped — a cursor asks about a PAGE, and the
38
+ * count is about the whole result set. Keeping one would also cache the
39
+ * same total once per page a person walked through.
40
+ * - `sort` dropped — the total does not depend on the order, and keeping it
41
+ * would miss the cache every time somebody changed the sort.
42
+ *
43
+ * Everything else — `q`, `category`, `owner`, filters, ranges, geo, `lang` —
44
+ * is exactly what a real search would carry, because those are what change the
45
+ * answer.
46
+ *
47
+ * ── The count is a LoadState, and the number is not always a number ────────
48
+ *
49
+ * The ready value carries `countKind`: `"exact"` is a total, `"at_least"` is
50
+ * a floor, `"unknown"` is the engine declining to say — and `count: null`
51
+ * under `"unknown"` is never `0`. A skin that renders the floor as a total is
52
+ * the defect `state/degradations.ts` exists to prevent, so the kind travels
53
+ * with the number rather than beside it.
54
+ */
55
+ import { useEffect, useRef, useState } from "react";
56
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
57
+ import type { LoadState } from "@stapel/core";
58
+ import { searchQueryParams } from "../api/searchApi.js";
59
+ import type { SearchQueryState } from "../api/types.js";
60
+ import { useSearchQuery } from "../model/queries.js";
61
+ import {
62
+ SEARCH_COUNT_DEBOUNCE_MS,
63
+ SEARCH_COUNT_PAGE_SIZE,
64
+ } from "../state/limits.js";
65
+ import { countKind, parseDegradations } from "../state/degradations.js";
66
+ import type { SearchCountKind } from "../state/degradations.js";
67
+
68
+ /** What a count read answers. */
69
+ export interface SearchCount {
70
+ /** `null` is "the engine cannot say" — a real state, and NOT zero. */
71
+ readonly count: number | null;
72
+ /** Whether that number may be spoken as a total, as a floor, or at all. */
73
+ readonly kind: SearchCountKind;
74
+ }
75
+
76
+ /**
77
+ * The state a count is asked about: a doc type plus as much or as little of a
78
+ * search as the caller has composed. Partial on purpose — a panel holding one
79
+ * chosen facet is a legitimate question, and making it build a full
80
+ * {@link SearchQueryState} would mean spelling `filters: {}` and `ranges: {}`
81
+ * at every call site.
82
+ */
83
+ export type SearchCountState = Pick<SearchQueryState, "type"> &
84
+ Partial<Omit<SearchQueryState, "type">>;
85
+
86
+ export interface UseSearchCountOptions {
87
+ /**
88
+ * Ask at all. Default `true`. `false` holds the hook at `loading` (the
89
+ * fleet's shape for "no answer yet"), so a panel that is still resolving its
90
+ * category does not ask about a state it has not finished building.
91
+ */
92
+ readonly enabled?: boolean;
93
+ /**
94
+ * Quiet time before a CHANGED state is asked about (default
95
+ * {@link SEARCH_COUNT_DEBOUNCE_MS}). The first state is asked about
96
+ * immediately — a panel that opens should not wait a quarter second to say
97
+ * its number — and every change after that is coalesced, because a count
98
+ * rides the full query (see this file's header) and a request per keystroke
99
+ * is how a throttled endpoint starts answering 429.
100
+ *
101
+ * `0` disables it, for a caller whose state only changes on a commit.
102
+ */
103
+ readonly debounceMs?: number;
104
+ }
105
+
106
+ /**
107
+ * The wire form of a count question — exported so a test (or a host building
108
+ * its own key) can see exactly what is asked, rather than inferring it.
109
+ */
110
+ export function countQueryState(state: SearchCountState): SearchQueryState {
111
+ const {
112
+ anchor: _anchor,
113
+ direction: _direction,
114
+ sort: _sort,
115
+ ...rest
116
+ } = state;
117
+ return {
118
+ q: "",
119
+ filters: {},
120
+ ranges: {},
121
+ ...rest,
122
+ facets: "off",
123
+ limit: SEARCH_COUNT_PAGE_SIZE,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * A stable string for "is this the same question". Built from the same wire
129
+ * object the query key is built from, with the keys sorted, so two states
130
+ * assembled in a different order are one question and not two.
131
+ */
132
+ function questionOf(state: SearchQueryState): string {
133
+ const params = searchQueryParams(state);
134
+ return JSON.stringify(
135
+ Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
136
+ );
137
+ }
138
+
139
+ export function useSearchCount(
140
+ state: SearchCountState,
141
+ options: UseSearchCountOptions = {}
142
+ ): LoadState<SearchCount> {
143
+ const target = countQueryState(state);
144
+ const question = questionOf(target);
145
+ const debounceMs = options.debounceMs ?? SEARCH_COUNT_DEBOUNCE_MS;
146
+
147
+ // The state the timer will settle ON when it fires — always the newest one,
148
+ // not the one that started the timer. Typing "hond" then "honda" must ask
149
+ // about "honda" once, never about "hond" late.
150
+ const latest = useRef(target);
151
+ latest.current = target;
152
+
153
+ const [settled, setSettled] = useState<{
154
+ readonly question: string;
155
+ readonly state: SearchQueryState;
156
+ }>(() => ({ question, state: target }));
157
+
158
+ useEffect(() => {
159
+ if (settled.question === question) return undefined;
160
+ if (debounceMs <= 0) {
161
+ setSettled({ question, state: latest.current });
162
+ return undefined;
163
+ }
164
+ const handle = setTimeout(() => {
165
+ setSettled({ question, state: latest.current });
166
+ }, debounceMs);
167
+ return () => {
168
+ clearTimeout(handle);
169
+ };
170
+ }, [question, settled.question, debounceMs]);
171
+
172
+ // `useSearchQuery` keeps the previous answer on screen while the next one is
173
+ // in flight (`keepPreviousData`), which is what a counted button wants: the
174
+ // number belongs to the last state we actually asked about, and it does not
175
+ // blank between two of them.
176
+ const query = useSearchQuery(settled.state, {
177
+ enabled: options.enabled ?? true,
178
+ });
179
+
180
+ return mapLoad(loadStateFromQuery(query), (data) => ({
181
+ count: data.count,
182
+ kind: countKind(
183
+ data.count,
184
+ data.count_is_lower_bound,
185
+ data.exact_total,
186
+ parseDegradations(data.degraded)
187
+ ),
188
+ }));
189
+ }
package/src/i18n/es.ts CHANGED
@@ -109,6 +109,7 @@ export const searchI18nBundleEs: I18nDictionary = {
109
109
  "search.geo.radius_km": "A menos de {km} km",
110
110
  "search.geo.radius_label": "Radio, km",
111
111
  "search.geo.clear": "En cualquier lugar",
112
+ "search.geo.everywhere": "Buscando en todas partes",
112
113
  "search.geo.box": "Dentro del área mostrada",
113
114
  "search.geo.chosen_place": "Un lugar elegido en el mapa",
114
115
 
package/src/i18n/keys.ts CHANGED
@@ -151,6 +151,16 @@ export const SEARCH_I18N_KEYS = {
151
151
  geoRadiusKm: "search.geo.radius_km",
152
152
  geoRadiusLabel: "search.geo.radius_label",
153
153
  geoClear: "search.geo.clear",
154
+ /**
155
+ * What `<LocationSummaryLine>` says when NO location is applied.
156
+ *
157
+ * Not `search.geo.clear` reused: that is the label on a BUTTON that widens
158
+ * the search ("Anywhere"), and this is a STATEMENT about where the search is
159
+ * currently looking. The two are the same word in English and diverge the
160
+ * moment a translator treats one as an imperative — which is exactly the
161
+ * class of bug a shared key produces and nobody sees in the source locale.
162
+ */
163
+ geoEverywhere: "search.geo.everywhere",
154
164
  geoBox: "search.geo.box",
155
165
  /**
156
166
  * What a location constraint is called when nobody has given it a NAME.
@@ -326,6 +336,7 @@ export const searchI18nBundleEn: Record<string, string> = {
326
336
  "search.geo.radius_km": "Within {km} km",
327
337
  "search.geo.radius_label": "Radius, km",
328
338
  "search.geo.clear": "Anywhere",
339
+ "search.geo.everywhere": "Searching everywhere",
329
340
  "search.geo.box": "Inside the shown area",
330
341
  "search.geo.chosen_place": "A chosen place on the map",
331
342
 
package/src/i18n/ru.ts CHANGED
@@ -136,6 +136,7 @@ export const searchI18nBundleRu: I18nDictionary = {
136
136
  "search.geo.radius_km": "В радиусе {km} км",
137
137
  "search.geo.radius_label": "Радиус, км",
138
138
  "search.geo.clear": "Везде",
139
+ "search.geo.everywhere": "Ищем везде",
139
140
  "search.geo.box": "В показанной области",
140
141
  "search.geo.chosen_place": "Выбранное место на карте",
141
142
 
package/src/index.ts CHANGED
@@ -113,6 +113,8 @@ export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
113
113
  export {
114
114
  SEARCH_BOX_DEBOUNCE_MS,
115
115
  SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
116
+ SEARCH_COUNT_DEBOUNCE_MS,
117
+ SEARCH_COUNT_PAGE_SIZE,
116
118
  SEARCH_DEFAULT_PAGE_SIZE,
117
119
  SEARCH_MAX_PAGE_SIZE,
118
120
  SEARCH_QUERY_MAX_CHARS,
@@ -152,6 +154,12 @@ export type { FacetPanelBag } from "./headless/FacetPanel.js";
152
154
  export { useAppliedSort } from "./headless/useAppliedSort.js";
153
155
  export { useSearchBox } from "./headless/useSearchBox.js";
154
156
  export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
157
+ export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
158
+ export type {
159
+ SearchCount,
160
+ SearchCountState,
161
+ UseSearchCountOptions,
162
+ } from "./headless/useSearchCount.js";
155
163
  export { RankingDisclosure } from "./headless/RankingDisclosure.js";
156
164
  export type { RankingDisclosureBag } from "./headless/RankingDisclosure.js";
157
165
 
@@ -27,6 +27,29 @@ export const SEARCH_MAX_PAGE_SIZE = 100;
27
27
  /** `services.suggest` clamps `limit` into `1..25`. */
28
28
  export const SUGGEST_MAX_LIMIT = 25;
29
29
 
30
+ /**
31
+ * The page a COUNT read asks for.
32
+ *
33
+ * There is no count-only endpoint (`SearchApi` is query/suggest/ranking), so
34
+ * `useSearchCount` rides the ordinary query and takes the total out of the
35
+ * envelope. One row is the smallest page the endpoint documents — `limit=0` is
36
+ * not a page size — and it is the difference between serializing one card
37
+ * nobody renders and serializing the default twenty-four.
38
+ */
39
+ export const SEARCH_COUNT_PAGE_SIZE = 1;
40
+
41
+ /**
42
+ * How long a composing panel has to stop changing before its count is asked
43
+ * for.
44
+ *
45
+ * Shorter than the search commit (350ms): a count follows discrete choices — a
46
+ * select, a chip, a range end — rather than keystrokes, and a button whose
47
+ * number arrives a third of a second after the choice reads as broken. Longer
48
+ * than zero: the read is a full query (see `SEARCH_COUNT_PAGE_SIZE`), so a
49
+ * person walking a select's options must not spend one search per option.
50
+ */
51
+ export const SEARCH_COUNT_DEBOUNCE_MS = 250;
52
+
30
53
  /**
31
54
  * The shortest prefix worth asking the index about.
32
55
  *