@stapel/search-react 0.8.0 → 0.9.1
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.
- package/CHANGELOG.md +42 -0
- package/dist/default/FacetPanelPane.d.ts +30 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +25 -7
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +9 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +8 -9
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts +19 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +18 -4
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +6 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +35 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +38 -3
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.js +1 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +12 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +13 -2
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.js +1 -1
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +3 -3
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetPanelPane.tsx +46 -6
- package/src/default/FilterChips.tsx +17 -9
- package/src/default/SearchPage.tsx +40 -1
- package/src/default/index.ts +6 -1
- package/src/headless/SearchStateProvider.tsx +80 -3
- package/src/i18n/es.ts +1 -1
- package/src/i18n/keys.ts +13 -2
- package/src/i18n/ru.ts +1 -1
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
} from "react";
|
|
2
9
|
import type { ReactElement, ReactNode } from "react";
|
|
3
10
|
import type {
|
|
4
11
|
SearchGeo,
|
|
@@ -74,6 +81,41 @@ const StateContext = createContext<SearchStateBag | null>(null);
|
|
|
74
81
|
|
|
75
82
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
76
83
|
readonly adapter: SearchParamsAdapter;
|
|
84
|
+
/**
|
|
85
|
+
* Where to centre the results when the URL says nothing about location —
|
|
86
|
+
* the visitor's own position, as the HOST resolved it (a granted browser
|
|
87
|
+
* geolocation prompt, or the server's IP guess when there was none).
|
|
88
|
+
*
|
|
89
|
+
* Opt-in, and it is deliberately not one of the `default*` parse options
|
|
90
|
+
* beside it. Those are read-time fallbacks: `defaultCategory` fills a gap in
|
|
91
|
+
* the parsed state and never reaches the query string. A location cannot
|
|
92
|
+
* work that way. It has to be WRITTEN, once, because the URL is the state:
|
|
93
|
+
* a centre that lived only in the parse would vanish from a link the visitor
|
|
94
|
+
* shares, and — worse — would be re-applied on the very next render after
|
|
95
|
+
* they cleared it, which is a filter that will not come off.
|
|
96
|
+
*
|
|
97
|
+
* So the rules, all four of which are about not overruling a person:
|
|
98
|
+
*
|
|
99
|
+
* - **Only into an empty URL.** A link that carries `lat`/`lon` or `bbox`
|
|
100
|
+
* already means a place, and it must mean the same place for everybody
|
|
101
|
+
* who opens it. A default that overwrote it would make one address bar
|
|
102
|
+
* two different searches.
|
|
103
|
+
* - **Once.** Tracked as "has anyone spoken about location yet", in a ref —
|
|
104
|
+
* not by comparing the default against the current value, which cannot
|
|
105
|
+
* tell "the visitor cleared it" from "it has not been applied".
|
|
106
|
+
* - **Never after a clear.** `setGeo` marks the question answered, so a
|
|
107
|
+
* person who widens the search back to everywhere stays there.
|
|
108
|
+
* - **Late is fine.** A browser prompt and an IP round trip both resolve
|
|
109
|
+
* after the first paint, so `undefined` now and a value three renders
|
|
110
|
+
* later still applies — provided the URL is still empty of geo at that
|
|
111
|
+
* moment.
|
|
112
|
+
*
|
|
113
|
+
* It REPLACES the history entry rather than pushing one: the visitor did not
|
|
114
|
+
* perform this change, and Back should leave the page, not undo a centring
|
|
115
|
+
* they never asked for. The adapter's `setParams` takes `{ replace: true }`
|
|
116
|
+
* for exactly this, and the react-router binding honours it.
|
|
117
|
+
*/
|
|
118
|
+
readonly defaultGeo?: SearchGeo | undefined;
|
|
77
119
|
readonly children: ReactNode;
|
|
78
120
|
}
|
|
79
121
|
|
|
@@ -86,7 +128,7 @@ export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
|
86
128
|
export function SearchStateProvider(
|
|
87
129
|
props: SearchStateProviderProps
|
|
88
130
|
): ReactElement {
|
|
89
|
-
const { adapter, children, ...parseOptions } = props;
|
|
131
|
+
const { adapter, children, defaultGeo, ...parseOptions } = props;
|
|
90
132
|
const { params, setParams } = adapter;
|
|
91
133
|
|
|
92
134
|
// The parse options are spread into a stable dependency: a host that builds
|
|
@@ -123,6 +165,17 @@ export function SearchStateProvider(
|
|
|
123
165
|
[setParams, search]
|
|
124
166
|
);
|
|
125
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Has anything at all said where to search yet?
|
|
170
|
+
*
|
|
171
|
+
* `true` once the URL was seen carrying a location, once `defaultGeo` has
|
|
172
|
+
* been applied, or once anybody called `setGeo` — including with `null`.
|
|
173
|
+
* That last one is the whole reason this is a ref and not a comparison: a
|
|
174
|
+
* cleared location and an unapplied default look identical in the state, and
|
|
175
|
+
* only the record of who spoke tells them apart.
|
|
176
|
+
*/
|
|
177
|
+
const geoSettled = useRef(false);
|
|
178
|
+
|
|
126
179
|
const bag = useMemo<SearchStateBag>(() => {
|
|
127
180
|
const state = parsed.state;
|
|
128
181
|
const apply = (
|
|
@@ -146,7 +199,13 @@ export function SearchStateProvider(
|
|
|
146
199
|
toggleFilter: (slug, value) => apply(toggleFilterValue(state, slug, value)),
|
|
147
200
|
setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
|
|
148
201
|
setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
|
|
149
|
-
setGeo: (geo) =>
|
|
202
|
+
setGeo: (geo) => {
|
|
203
|
+
// The person has now answered the location question themselves —
|
|
204
|
+
// including by answering "anywhere". `defaultGeo` does not get to ask
|
|
205
|
+
// it again.
|
|
206
|
+
geoSettled.current = true;
|
|
207
|
+
apply(patchSearchState(state, { geo }));
|
|
208
|
+
},
|
|
150
209
|
// A page size is a preference, not a step through the results.
|
|
151
210
|
setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
|
|
152
211
|
clearAll: () => apply(clearFilters(state)),
|
|
@@ -156,6 +215,24 @@ export function SearchStateProvider(
|
|
|
156
215
|
};
|
|
157
216
|
}, [parsed, commit]);
|
|
158
217
|
|
|
218
|
+
// An effect, not a render-time write: this puts a value in the URL, and a
|
|
219
|
+
// router asked to navigate during a render is a warning at best and a loop
|
|
220
|
+
// at worst. It runs after every commit, which is what makes a `defaultGeo`
|
|
221
|
+
// that arrives on the fourth render — the browser prompt finally answered —
|
|
222
|
+
// land as reliably as one present on the first.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (geoSettled.current) return;
|
|
225
|
+
if (parsed.state.geo !== undefined) {
|
|
226
|
+
// The link brought its own place. That settles the question for this
|
|
227
|
+
// visit even if the host is still resolving one of its own.
|
|
228
|
+
geoSettled.current = true;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (defaultGeo === undefined) return;
|
|
232
|
+
geoSettled.current = true;
|
|
233
|
+
commit(patchSearchState(parsed.state, { geo: defaultGeo }), { replace: true });
|
|
234
|
+
}, [defaultGeo, parsed.state, commit]);
|
|
235
|
+
|
|
159
236
|
return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
|
|
160
237
|
}
|
|
161
238
|
|
package/src/i18n/es.ts
CHANGED
|
@@ -110,7 +110,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
110
110
|
"search.geo.radius_label": "Radio, km",
|
|
111
111
|
"search.geo.clear": "En cualquier lugar",
|
|
112
112
|
"search.geo.box": "Dentro del área mostrada",
|
|
113
|
-
"search.geo.
|
|
113
|
+
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
|
114
114
|
|
|
115
115
|
"search.url.issues_title": "Parte de este enlace no se pudo leer",
|
|
116
116
|
"search.url.issue.not_a_number":
|
package/src/i18n/keys.ts
CHANGED
|
@@ -152,7 +152,18 @@ export const SEARCH_I18N_KEYS = {
|
|
|
152
152
|
geoRadiusLabel: "search.geo.radius_label",
|
|
153
153
|
geoClear: "search.geo.clear",
|
|
154
154
|
geoBox: "search.geo.box",
|
|
155
|
-
|
|
155
|
+
/**
|
|
156
|
+
* What a location constraint is called when nobody has given it a NAME.
|
|
157
|
+
*
|
|
158
|
+
* It replaces `search.geo.center` ("Around {lat}, {lon}"), which printed the
|
|
159
|
+
* two numbers the URL happens to store — `55.756, 37.617` — to a person who
|
|
160
|
+
* chose a place. A coordinate is storage, not a description: it cannot be
|
|
161
|
+
* checked by the one reader who could check an address, so a wrong point
|
|
162
|
+
* reads as authoritative and a right one reads as machinery. Hosts that HAVE
|
|
163
|
+
* the name (the geocoder that resolved it, the IP guess's city) pass
|
|
164
|
+
* `geoLabel` and this sentence never appears.
|
|
165
|
+
*/
|
|
166
|
+
geoChosenPlace: "search.geo.chosen_place",
|
|
156
167
|
|
|
157
168
|
// ── the URL that could not be read ───────────────────────────────────────
|
|
158
169
|
urlIssuesTitle: "search.url.issues_title",
|
|
@@ -316,7 +327,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
316
327
|
"search.geo.radius_label": "Radius, km",
|
|
317
328
|
"search.geo.clear": "Anywhere",
|
|
318
329
|
"search.geo.box": "Inside the shown area",
|
|
319
|
-
"search.geo.
|
|
330
|
+
"search.geo.chosen_place": "A chosen place on the map",
|
|
320
331
|
|
|
321
332
|
"search.url.issues_title": "Part of this link could not be read",
|
|
322
333
|
"search.url.issue.not_a_number": "“{param}” in this link is not a number, so it was ignored",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -137,7 +137,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
137
137
|
"search.geo.radius_label": "Радиус, км",
|
|
138
138
|
"search.geo.clear": "Везде",
|
|
139
139
|
"search.geo.box": "В показанной области",
|
|
140
|
-
"search.geo.
|
|
140
|
+
"search.geo.chosen_place": "Выбранное место на карте",
|
|
141
141
|
|
|
142
142
|
"search.url.issues_title": "Часть этой ссылки прочитать не удалось",
|
|
143
143
|
"search.url.issue.not_a_number":
|