@stapel/search-react 0.7.0 → 0.9.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.
- package/CHANGELOG.md +61 -0
- package/dist/default/FacetGroupControl.d.ts +40 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -0
- package/dist/default/FacetGroupControl.js +176 -0
- package/dist/default/FacetGroupControl.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +30 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +28 -16
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +37 -0
- package/dist/default/FilterChips.d.ts.map +1 -0
- package/dist/default/FilterChips.js +203 -0
- package/dist/default/FilterChips.js.map +1 -0
- package/dist/default/SearchPage.d.ts +59 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +60 -10
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +19 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +15 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/ViewSwitch.d.ts +66 -0
- package/dist/default/ViewSwitch.d.ts.map +1 -0
- package/dist/default/ViewSwitch.js +49 -0
- package/dist/default/ViewSwitch.js.map +1 -0
- package/dist/default/index.d.ts +12 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +9 -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.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +32 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +42 -2
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -1
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +32 -3
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +312 -0
- package/src/default/FacetPanelPane.tsx +57 -63
- package/src/default/FilterChips.tsx +423 -0
- package/src/default/SearchPage.tsx +158 -16
- package/src/default/SearchResultsPane.tsx +39 -2
- package/src/default/ViewSwitch.tsx +147 -0
- package/src/default/index.ts +23 -1
- package/src/headless/SearchStateProvider.tsx +80 -3
- package/src/i18n/es.ts +10 -1
- package/src/i18n/keys.ts +44 -2
- package/src/i18n/ru.ts +10 -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
|
@@ -55,6 +55,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
55
55
|
"search.sort.distance": "Más cercanos",
|
|
56
56
|
"search.sort.server_chose": "Ordenado por {sort}",
|
|
57
57
|
|
|
58
|
+
"search.view.label": "Vista",
|
|
59
|
+
"search.view.list": "Lista",
|
|
60
|
+
"search.view.grid": "Cuadrícula",
|
|
61
|
+
|
|
58
62
|
"search.facets.title": "Filtros",
|
|
59
63
|
"search.facets.loading": "Cargando filtros…",
|
|
60
64
|
"search.facets.load_failed": "No pudimos cargar los filtros.",
|
|
@@ -74,6 +78,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
74
78
|
"search.facets.range_clear": "Quitar",
|
|
75
79
|
"search.facets.range_from_aria": "{feature}, desde",
|
|
76
80
|
"search.facets.range_to_aria": "{feature}, hasta",
|
|
81
|
+
"search.facets.show_all": "Ver todos ({count})",
|
|
82
|
+
"search.facets.show_less": "Ver menos",
|
|
77
83
|
"search.facets.range_invalid":
|
|
78
84
|
"«Desde» es mayor que «hasta», así que nada podría coincidir. Intercámbialos para aplicar el rango.",
|
|
79
85
|
|
|
@@ -84,6 +90,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
84
90
|
"search.filters.show_count_at_least.one": "Ver {count}+ resultado",
|
|
85
91
|
"search.filters.show_count_at_least.other": "Ver {count}+ resultados",
|
|
86
92
|
"search.filters.dismiss": "Cerrar los filtros",
|
|
93
|
+
"search.filters.chips_label": "Filtros",
|
|
94
|
+
"search.filters.all": "Todos los filtros",
|
|
95
|
+
"search.filters.chip_more": ", +{count}",
|
|
87
96
|
|
|
88
97
|
"search.category.title": "Categoría",
|
|
89
98
|
"search.category.clear": "Buscar en todo el catálogo",
|
|
@@ -101,7 +110,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
101
110
|
"search.geo.radius_label": "Radio, km",
|
|
102
111
|
"search.geo.clear": "En cualquier lugar",
|
|
103
112
|
"search.geo.box": "Dentro del área mostrada",
|
|
104
|
-
"search.geo.
|
|
113
|
+
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
|
105
114
|
|
|
106
115
|
"search.url.issues_title": "Parte de este enlace no se pudo leer",
|
|
107
116
|
"search.url.issue.not_a_number":
|
package/src/i18n/keys.ts
CHANGED
|
@@ -74,6 +74,13 @@ export const SEARCH_I18N_KEYS = {
|
|
|
74
74
|
sortDistance: "search.sort.distance",
|
|
75
75
|
sortServerChose: "search.sort.server_chose",
|
|
76
76
|
|
|
77
|
+
// ── how the results are ARRANGED (see ViewSwitch) ────────────────────────
|
|
78
|
+
/** The switch's own accessible name — a `Segmented` renders a radiogroup
|
|
79
|
+
* with no `<label for>` to name it. */
|
|
80
|
+
viewLabel: "search.view.label",
|
|
81
|
+
viewList: "search.view.list",
|
|
82
|
+
viewGrid: "search.view.grid",
|
|
83
|
+
|
|
77
84
|
// ── facets ───────────────────────────────────────────────────────────────
|
|
78
85
|
facetsTitle: "search.facets.title",
|
|
79
86
|
facetsLoading: "search.facets.loading",
|
|
@@ -96,6 +103,12 @@ export const SEARCH_I18N_KEYS = {
|
|
|
96
103
|
facetsRangeToAria: "search.facets.range_to_aria",
|
|
97
104
|
/** The reason "Apply" is off: the range reads backwards. */
|
|
98
105
|
facetsRangeInvalid: "search.facets.range_invalid",
|
|
106
|
+
/** The fold on a long facet group: "Show all (46)". The number is in
|
|
107
|
+
* brackets and not a counted noun, so this is one message and not a plural
|
|
108
|
+
* family — "(1)" never renders, because a group is only folded when the
|
|
109
|
+
* tail is longer than one row. */
|
|
110
|
+
facetsShowAll: "search.facets.show_all",
|
|
111
|
+
facetsShowLess: "search.facets.show_less",
|
|
99
112
|
|
|
100
113
|
// ── the filter panel as a whole (phone sheet + host slots) ───────────────
|
|
101
114
|
filtersOpen: "search.filters.open",
|
|
@@ -107,6 +120,15 @@ export const SEARCH_I18N_KEYS = {
|
|
|
107
120
|
/** "Show N+ results" — a FLOOR on the same button. A PLURAL FAMILY. */
|
|
108
121
|
filtersShowCountAtLeast: "search.filters.show_count_at_least",
|
|
109
122
|
filtersDismiss: "search.filters.dismiss",
|
|
123
|
+
/** The accessible name of the phone chip ROW — a `role="group"` holding a
|
|
124
|
+
* dozen buttons is announced as nothing at all without one. */
|
|
125
|
+
filtersChipsLabel: "search.filters.chips_label",
|
|
126
|
+
/** The leading, icon-only chip: the whole panel. Icon-only means the name
|
|
127
|
+
* exists ONLY here, so this key is the control's entire accessibility. */
|
|
128
|
+
filtersAll: "search.filters.all",
|
|
129
|
+
/** A chip filtering on more than one value: "Bosch, +2". Not a plural
|
|
130
|
+
* family — nothing is being counted in words. */
|
|
131
|
+
filtersChipMore: "search.filters.chip_more",
|
|
110
132
|
|
|
111
133
|
// ── category (a host slot, plus the control that removes it) ─────────────
|
|
112
134
|
categoryTitle: "search.category.title",
|
|
@@ -130,7 +152,18 @@ export const SEARCH_I18N_KEYS = {
|
|
|
130
152
|
geoRadiusLabel: "search.geo.radius_label",
|
|
131
153
|
geoClear: "search.geo.clear",
|
|
132
154
|
geoBox: "search.geo.box",
|
|
133
|
-
|
|
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",
|
|
134
167
|
|
|
135
168
|
// ── the URL that could not be read ───────────────────────────────────────
|
|
136
169
|
urlIssuesTitle: "search.url.issues_title",
|
|
@@ -239,6 +272,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
239
272
|
"search.sort.distance": "Nearest first",
|
|
240
273
|
"search.sort.server_chose": "Sorted by {sort}",
|
|
241
274
|
|
|
275
|
+
"search.view.label": "View",
|
|
276
|
+
"search.view.list": "List",
|
|
277
|
+
"search.view.grid": "Grid",
|
|
278
|
+
|
|
242
279
|
"search.facets.title": "Filters",
|
|
243
280
|
"search.facets.loading": "Loading filters…",
|
|
244
281
|
"search.facets.load_failed": "We could not load the filters",
|
|
@@ -258,6 +295,8 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
258
295
|
"search.facets.range_clear": "Clear",
|
|
259
296
|
"search.facets.range_from_aria": "{feature}, from",
|
|
260
297
|
"search.facets.range_to_aria": "{feature}, up to",
|
|
298
|
+
"search.facets.show_all": "Show all ({count})",
|
|
299
|
+
"search.facets.show_less": "Show fewer",
|
|
261
300
|
"search.facets.range_invalid":
|
|
262
301
|
"“From” is larger than “to”, so nothing could match. Swap them to apply this range.",
|
|
263
302
|
|
|
@@ -268,6 +307,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
268
307
|
"search.filters.show_count_at_least.one": "Show {count}+ result",
|
|
269
308
|
"search.filters.show_count_at_least.other": "Show {count}+ results",
|
|
270
309
|
"search.filters.dismiss": "Close the filters",
|
|
310
|
+
"search.filters.chips_label": "Filters",
|
|
311
|
+
"search.filters.all": "All filters",
|
|
312
|
+
"search.filters.chip_more": ", +{count}",
|
|
271
313
|
|
|
272
314
|
"search.category.title": "Category",
|
|
273
315
|
"search.category.clear": "Search the whole catalogue",
|
|
@@ -285,7 +327,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
285
327
|
"search.geo.radius_label": "Radius, km",
|
|
286
328
|
"search.geo.clear": "Anywhere",
|
|
287
329
|
"search.geo.box": "Inside the shown area",
|
|
288
|
-
"search.geo.
|
|
330
|
+
"search.geo.chosen_place": "A chosen place on the map",
|
|
289
331
|
|
|
290
332
|
"search.url.issues_title": "Part of this link could not be read",
|
|
291
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
|
@@ -78,6 +78,10 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
78
78
|
"search.sort.distance": "Сначала ближние",
|
|
79
79
|
"search.sort.server_chose": "Отсортировано: {sort}",
|
|
80
80
|
|
|
81
|
+
"search.view.label": "Вид",
|
|
82
|
+
"search.view.list": "Списком",
|
|
83
|
+
"search.view.grid": "Плиткой",
|
|
84
|
+
|
|
81
85
|
"search.facets.title": "Фильтры",
|
|
82
86
|
"search.facets.loading": "Загружаем фильтры…",
|
|
83
87
|
"search.facets.load_failed": "Не удалось загрузить фильтры.",
|
|
@@ -97,6 +101,8 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
97
101
|
"search.facets.range_clear": "Сбросить",
|
|
98
102
|
"search.facets.range_from_aria": "{feature}, от",
|
|
99
103
|
"search.facets.range_to_aria": "{feature}, до",
|
|
104
|
+
"search.facets.show_all": "Показать все ({count})",
|
|
105
|
+
"search.facets.show_less": "Свернуть",
|
|
100
106
|
"search.facets.range_invalid":
|
|
101
107
|
"«От» больше, чем «до», — под такой диапазон ничего не подойдёт. Поменяйте значения местами.",
|
|
102
108
|
|
|
@@ -111,6 +117,9 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
111
117
|
"search.filters.show_count_at_least.many": "Показать {count}+ объявлений",
|
|
112
118
|
"search.filters.show_count_at_least.other": "Показать {count}+ объявления",
|
|
113
119
|
"search.filters.dismiss": "Закрыть фильтры",
|
|
120
|
+
"search.filters.chips_label": "Фильтры",
|
|
121
|
+
"search.filters.all": "Все фильтры",
|
|
122
|
+
"search.filters.chip_more": ", +{count}",
|
|
114
123
|
|
|
115
124
|
"search.category.title": "Категория",
|
|
116
125
|
"search.category.clear": "Искать по всему каталогу",
|
|
@@ -128,7 +137,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
128
137
|
"search.geo.radius_label": "Радиус, км",
|
|
129
138
|
"search.geo.clear": "Везде",
|
|
130
139
|
"search.geo.box": "В показанной области",
|
|
131
|
-
"search.geo.
|
|
140
|
+
"search.geo.chosen_place": "Выбранное место на карте",
|
|
132
141
|
|
|
133
142
|
"search.url.issues_title": "Часть этой ссылки прочитать не удалось",
|
|
134
143
|
"search.url.issue.not_a_number":
|