@stapel/search-react 0.27.0 → 0.29.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 +81 -0
- package/README.md +55 -3
- package/dist/default/FacetGroupControl.d.ts +33 -6
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +167 -4
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +5 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +1 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/OtherCategoriesLine.d.ts +21 -0
- package/dist/default/OtherCategoriesLine.d.ts.map +1 -1
- package/dist/default/OtherCategoriesLine.js +28 -6
- package/dist/default/OtherCategoriesLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts +14 -7
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -3
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +4 -1
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +2 -0
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +9 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +1 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +38 -1
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +70 -19
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +15 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +18 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +13 -4
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +9 -2
- package/nav-manifest.json +1 -1
- package/package.json +11 -11
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +278 -22
- package/src/default/FacetPanelPane.tsx +6 -3
- package/src/default/OtherCategoriesLine.tsx +52 -4
- package/src/default/SearchPage.tsx +25 -9
- package/src/default/SearchResultsPane.tsx +10 -1
- package/src/default/index.ts +2 -0
- package/src/headless/FacetPanel.tsx +10 -0
- package/src/headless/SearchStateProvider.tsx +99 -20
- package/src/i18n/es.ts +3 -0
- package/src/i18n/keys.ts +18 -0
- package/src/i18n/ru.ts +3 -0
- package/src/index.ts +4 -0
- package/src/state/urlState.ts +35 -4
|
@@ -86,7 +86,10 @@ 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 {
|
|
89
|
+
import type {
|
|
90
|
+
OtherCategoryHrefResolver,
|
|
91
|
+
OtherCategoryNamer,
|
|
92
|
+
} from "./OtherCategoriesLine.js";
|
|
90
93
|
import { SearchResultsPane } from "./SearchResultsPane.js";
|
|
91
94
|
import type { SearchResultsWrapper } from "./SearchResultsPane.js";
|
|
92
95
|
import { SortSelect } from "./SortSelect.js";
|
|
@@ -269,14 +272,18 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
269
272
|
* the two frames want opposite shapes and only this component knows which
|
|
270
273
|
* one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
|
|
271
274
|
* that opens the searchable list — a 418-value vocabulary held open in a
|
|
272
|
-
* 280px column is the whole column), the phone sheet gets `"
|
|
273
|
-
*
|
|
275
|
+
* 280px column is the whole column), the phone sheet gets `"sheet"` — the
|
|
276
|
+
* same trigger row, opening a nested picker with a search box, a
|
|
277
|
+
* recommended band and the rest.
|
|
274
278
|
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
279
|
+
* The phone default was `"inline"`, which drew the axis as a wall of
|
|
280
|
+
* checkboxes with a "Find a value" box over it while the COMPOSER's picker
|
|
281
|
+
* for the very same vocabulary was a trigger and a sheet. One dictionary,
|
|
282
|
+
* two gestures, depending on which half of the product you were in. Set it
|
|
283
|
+
* to override both; `"inline"` is still there for a surface that is
|
|
284
|
+
* already devoted to one group.
|
|
278
285
|
*/
|
|
279
|
-
readonly dictionaryMode?: "field" | "inline";
|
|
286
|
+
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
280
287
|
/**
|
|
281
288
|
* How many groups the rail draws before the rest fold behind "All filters"
|
|
282
289
|
* — see {@link FacetPanelPaneProps.visibleGroups}.
|
|
@@ -367,6 +374,9 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
367
374
|
/** What a category id path is CALLED, for the line above. The same question
|
|
368
375
|
* `categoryLabel` answers for the chip, asked once per row. */
|
|
369
376
|
readonly categoryName?: OtherCategoryNamer;
|
|
377
|
+
/** A real address for a category id path, for the line above — see
|
|
378
|
+
* {@link OtherCategoriesLineProps.categoryHref}. */
|
|
379
|
+
readonly categoryHref?: OtherCategoryHrefResolver;
|
|
370
380
|
/** What this surface calls its result list. See
|
|
371
381
|
* {@link SearchResultsPaneProps.heading}. */
|
|
372
382
|
readonly resultsHeading?: ReactNode;
|
|
@@ -449,7 +459,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
449
459
|
|
|
450
460
|
interface SearchPageBodyProps {
|
|
451
461
|
readonly renderCard?: SearchCardRenderer;
|
|
452
|
-
readonly dictionaryMode?: "field" | "inline";
|
|
462
|
+
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
453
463
|
readonly visibleGroups?: number | null;
|
|
454
464
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
455
465
|
readonly renderEmptyExits?: () => ReactNode;
|
|
@@ -468,6 +478,7 @@ interface SearchPageBodyProps {
|
|
|
468
478
|
readonly appliedChips?: boolean | "desktop";
|
|
469
479
|
readonly otherCategories?: boolean;
|
|
470
480
|
readonly categoryName?: OtherCategoryNamer;
|
|
481
|
+
readonly categoryHref?: OtherCategoryHrefResolver;
|
|
471
482
|
readonly resultsHeading?: ReactNode;
|
|
472
483
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
473
484
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
@@ -580,7 +591,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
580
591
|
: // STATIC, not sticky: the rail scrolls with the page, and a bar
|
|
581
592
|
// pinned to the port's floor sat on top of the last groups.
|
|
582
593
|
{ footerBar: "static" as const })}
|
|
583
|
-
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "
|
|
594
|
+
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "sheet" : "field")}
|
|
584
595
|
// `??` would treat an explicit `null` ("never fold") the same as
|
|
585
596
|
// "not set": `visibleGroups` uses `null` as a real value, unlike
|
|
586
597
|
// `dictionaryMode` above, so only `undefined` falls through.
|
|
@@ -659,6 +670,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
659
670
|
{...(props.categoryName !== undefined
|
|
660
671
|
? { categoryName: props.categoryName }
|
|
661
672
|
: {})}
|
|
673
|
+
{...(props.categoryHref !== undefined
|
|
674
|
+
? { categoryHref: props.categoryHref }
|
|
675
|
+
: {})}
|
|
662
676
|
{...(props.resultsHeading !== undefined
|
|
663
677
|
? { heading: props.resultsHeading }
|
|
664
678
|
: {})}
|
|
@@ -838,6 +852,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
838
852
|
appliedChips,
|
|
839
853
|
otherCategories,
|
|
840
854
|
categoryName,
|
|
855
|
+
categoryHref,
|
|
841
856
|
resultsHeading,
|
|
842
857
|
degradationNotice,
|
|
843
858
|
filtersLayout,
|
|
@@ -879,6 +894,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
879
894
|
{...(appliedChips !== undefined ? { appliedChips } : {})}
|
|
880
895
|
{...(otherCategories !== undefined ? { otherCategories } : {})}
|
|
881
896
|
{...(categoryName !== undefined ? { categoryName } : {})}
|
|
897
|
+
{...(categoryHref !== undefined ? { categoryHref } : {})}
|
|
882
898
|
{...(resultsHeading !== undefined ? { resultsHeading } : {})}
|
|
883
899
|
{...(degradationNotice !== undefined ? { degradationNotice } : {})}
|
|
884
900
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
@@ -47,7 +47,10 @@ import { DegradationNotice } from "./DegradationNotice.js";
|
|
|
47
47
|
import { EmptyExits } from "./EmptyExits.js";
|
|
48
48
|
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
49
49
|
import { OtherCategoriesLine } from "./OtherCategoriesLine.js";
|
|
50
|
-
import type {
|
|
50
|
+
import type {
|
|
51
|
+
OtherCategoryHrefResolver,
|
|
52
|
+
OtherCategoryNamer,
|
|
53
|
+
} from "./OtherCategoriesLine.js";
|
|
51
54
|
import { SearchResultCard } from "./SearchResultCard.js";
|
|
52
55
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
53
56
|
import type { SearchResultsLayout } from "./ViewSwitch.js";
|
|
@@ -277,6 +280,9 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
277
280
|
/** What a category id path is CALLED, for the line above — see
|
|
278
281
|
* {@link OtherCategoriesLineProps.categoryName}. */
|
|
279
282
|
readonly categoryName?: OtherCategoryNamer;
|
|
283
|
+
/** A real address for a category id path, for the line above — see
|
|
284
|
+
* {@link OtherCategoriesLineProps.categoryHref}. */
|
|
285
|
+
readonly categoryHref?: OtherCategoryHrefResolver;
|
|
280
286
|
}
|
|
281
287
|
|
|
282
288
|
function Count(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
@@ -400,6 +406,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
400
406
|
{...(props.categoryName !== undefined
|
|
401
407
|
? { categoryName: props.categoryName }
|
|
402
408
|
: {})}
|
|
409
|
+
{...(props.categoryHref !== undefined
|
|
410
|
+
? { categoryHref: props.categoryHref }
|
|
411
|
+
: {})}
|
|
403
412
|
/>
|
|
404
413
|
)}
|
|
405
414
|
|
package/src/default/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ export {
|
|
|
58
58
|
} from "./OtherCategoriesLine.js";
|
|
59
59
|
export type {
|
|
60
60
|
OtherCategoriesLineProps,
|
|
61
|
+
OtherCategoryHrefResolver,
|
|
61
62
|
OtherCategoryNamer,
|
|
62
63
|
} from "./OtherCategoriesLine.js";
|
|
63
64
|
|
|
@@ -106,6 +107,7 @@ export {
|
|
|
106
107
|
facetOptionNodes,
|
|
107
108
|
isDictionaryFacet,
|
|
108
109
|
FACET_DICTIONARY_THRESHOLD,
|
|
110
|
+
FACET_SHEET_PAGE,
|
|
109
111
|
FACET_VISIBLE_OPTIONS,
|
|
110
112
|
} from "./FacetGroupControl.js";
|
|
111
113
|
export type {
|
|
@@ -89,6 +89,15 @@ export interface FacetPanelBag {
|
|
|
89
89
|
readonly activeFilters: number;
|
|
90
90
|
|
|
91
91
|
toggle(slug: string, value: string): void;
|
|
92
|
+
/**
|
|
93
|
+
* Write one slug's chosen values WHOLE — the bulk half of `toggle`.
|
|
94
|
+
*
|
|
95
|
+
* `toggle` reads the state it flips, so several of them in one tick
|
|
96
|
+
* collapse into the last. A control that holds a draft and applies it once
|
|
97
|
+
* — the phone's dictionary sheet — needs to say the whole list, and a
|
|
98
|
+
* storefront replaying a saved search needs the same call.
|
|
99
|
+
*/
|
|
100
|
+
setValues(slug: string, values: readonly string[]): void;
|
|
92
101
|
setRange(slug: string, range: SearchRange | null): void;
|
|
93
102
|
clear(slug: string): void;
|
|
94
103
|
clearAll(): void;
|
|
@@ -224,6 +233,7 @@ export function useFacetPanel(props: {
|
|
|
224
233
|
envelope.data.degraded.includes(FACET_PLAN_EVIDENCE),
|
|
225
234
|
activeFilters,
|
|
226
235
|
toggle: toggleFilter,
|
|
236
|
+
setValues: setFilter,
|
|
227
237
|
setRange,
|
|
228
238
|
clear: (slug) => {
|
|
229
239
|
setFilter(slug, []);
|
|
@@ -50,11 +50,79 @@ export interface SearchParamsAdapter {
|
|
|
50
50
|
* `replace` is meaningful, not decorative: a FILTER change pushes (so Back
|
|
51
51
|
* removes exactly the last filter — the spec's §4.2 acceptance), while a
|
|
52
52
|
* correction that the person did not perform replaces. Callers below pass
|
|
53
|
-
* it deliberately
|
|
53
|
+
* it deliberately, following {@link DEFAULT_HISTORY_MODE} — see that table
|
|
54
|
+
* for which change gets which. An adapter that ignores `replace` breaks the
|
|
55
|
+
* Back button.
|
|
54
56
|
*/
|
|
55
57
|
setParams(next: URLSearchParams, options?: { readonly replace?: boolean }): void;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/** One kind of change a control can make to the search — the unit
|
|
61
|
+
* {@link DEFAULT_HISTORY_MODE} assigns a history mode to. */
|
|
62
|
+
export type SearchHistoryKind =
|
|
63
|
+
| "text"
|
|
64
|
+
| "sort"
|
|
65
|
+
| "category"
|
|
66
|
+
| "language"
|
|
67
|
+
| "filter"
|
|
68
|
+
| "range"
|
|
69
|
+
| "geo"
|
|
70
|
+
| "limit"
|
|
71
|
+
| "clear"
|
|
72
|
+
| "page"
|
|
73
|
+
| "patch";
|
|
74
|
+
|
|
75
|
+
/** `"push"` opens a new history entry; `"replace"` overwrites the current one. */
|
|
76
|
+
export type HistoryMode = "push" | "replace";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* THE HISTORY POLICY — one place stating which change gets its own Back step.
|
|
80
|
+
*
|
|
81
|
+
* `"push"` is what makes Back undo exactly one thing: choosing or removing a
|
|
82
|
+
* facet value (`filter`), narrowing or widening a range (`range`), picking a
|
|
83
|
+
* partition or another category (`category` — `<PartitionChips>` and
|
|
84
|
+
* `<OtherCategoriesLine>` both go through `setCategory`), applying a place
|
|
85
|
+
* (`geo`) or a sort (`sort`) are all decisions a person can want to take back
|
|
86
|
+
* one press at a time, so each one opens its own entry — this is the
|
|
87
|
+
* behaviour spec §4.2 tests: "press Back and lose exactly the last filter".
|
|
88
|
+
*
|
|
89
|
+
* `"replace"` is for a change too fine-grained, or too incidental, to be a
|
|
90
|
+
* Back-able step of its own:
|
|
91
|
+
*
|
|
92
|
+
* - `text` — one history entry per keystroke would make Back useless long
|
|
93
|
+
* before it reached the filter underneath;
|
|
94
|
+
* - `limit` — a page-size preference, not a narrowing;
|
|
95
|
+
* - `page` — a keyset cursor move is SCROLLING, not a decision. Back from
|
|
96
|
+
* page 3 has to land where the visitor WAS (off the pager, before they
|
|
97
|
+
* started paging), not quietly on page 2 with the pager still showing —
|
|
98
|
+
* the reference behaves the first way, and a mutator that pushed here used
|
|
99
|
+
* to make Back page backwards forever instead of leaving the results.
|
|
100
|
+
*
|
|
101
|
+
* Every mutator {@link SearchStateBag} ships follows this table; `clear` and
|
|
102
|
+
* the `patch` escape hatch push by default like any other applied change. A
|
|
103
|
+
* host that disagrees can still call {@link SearchStateBag.patch} directly
|
|
104
|
+
* with its own `replace` — the table governs this pair's own controls, not
|
|
105
|
+
* every possible call.
|
|
106
|
+
*/
|
|
107
|
+
export const DEFAULT_HISTORY_MODE: Readonly<Record<SearchHistoryKind, HistoryMode>> = {
|
|
108
|
+
text: "replace",
|
|
109
|
+
sort: "push",
|
|
110
|
+
category: "push",
|
|
111
|
+
language: "push",
|
|
112
|
+
filter: "push",
|
|
113
|
+
range: "push",
|
|
114
|
+
geo: "push",
|
|
115
|
+
limit: "replace",
|
|
116
|
+
clear: "push",
|
|
117
|
+
page: "replace",
|
|
118
|
+
patch: "push",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** {@link DEFAULT_HISTORY_MODE} as the `setParams` options it produces. */
|
|
122
|
+
function historyOptions(kind: SearchHistoryKind): { readonly replace?: boolean } {
|
|
123
|
+
return DEFAULT_HISTORY_MODE[kind] === "replace" ? { replace: true } : {};
|
|
124
|
+
}
|
|
125
|
+
|
|
58
126
|
/** Everything a control needs to read and move the search. */
|
|
59
127
|
export interface SearchStateBag {
|
|
60
128
|
/** The current state, parsed from the URL. Nothing else holds a copy. */
|
|
@@ -292,11 +360,15 @@ export function SearchStateProvider(
|
|
|
292
360
|
const commit = useCallback(
|
|
293
361
|
(next: SearchQueryState, options?: { readonly replace?: boolean }): void => {
|
|
294
362
|
setParams(
|
|
295
|
-
writeSearchState(next, new URLSearchParams(search), facetKeys
|
|
363
|
+
writeSearchState(next, new URLSearchParams(search), facetKeys, {
|
|
364
|
+
defaultType,
|
|
365
|
+
...(defaultSort !== undefined ? { defaultSort } : {}),
|
|
366
|
+
...(defaultLimit !== undefined ? { defaultLimit } : {}),
|
|
367
|
+
}),
|
|
296
368
|
options
|
|
297
369
|
);
|
|
298
370
|
},
|
|
299
|
-
[setParams, search, facetKeys]
|
|
371
|
+
[setParams, search, facetKeys, defaultType, defaultSort, defaultLimit]
|
|
300
372
|
);
|
|
301
373
|
|
|
302
374
|
|
|
@@ -337,31 +409,38 @@ export function SearchStateProvider(
|
|
|
337
409
|
geoOffer: state.geo === undefined ? offer : undefined,
|
|
338
410
|
geoIsOffer: sameCenter(state.geo, offer),
|
|
339
411
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
setLanguage: (lang) =>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
412
|
+
// Every history mode below follows DEFAULT_HISTORY_MODE — see that
|
|
413
|
+
// table for the reasoning behind which kind pushes and which replaces.
|
|
414
|
+
setText: (q) => apply(patchSearchState(state, { q }), historyOptions("text")),
|
|
415
|
+
setSort: (sort) => apply(patchSearchState(state, { sort }), historyOptions("sort")),
|
|
416
|
+
setCategory: (category) =>
|
|
417
|
+
apply(patchSearchState(state, { category }), historyOptions("category")),
|
|
418
|
+
setLanguage: (lang) =>
|
|
419
|
+
apply(patchSearchState(state, { lang }), historyOptions("language")),
|
|
420
|
+
toggleFilter: (slug, value) =>
|
|
421
|
+
apply(toggleFilterValue(state, slug, value), historyOptions("filter")),
|
|
422
|
+
setFilter: (slug, values) =>
|
|
423
|
+
apply(setFilterValues(state, slug, values), historyOptions("filter")),
|
|
424
|
+
setRange: (slug, range) =>
|
|
425
|
+
apply(setRangeValue(state, slug, range), historyOptions("range")),
|
|
350
426
|
setGeo: (geo) => {
|
|
351
|
-
apply(patchSearchState(state, { geo }));
|
|
427
|
+
apply(patchSearchState(state, { geo }), historyOptions("geo"));
|
|
352
428
|
},
|
|
353
429
|
acceptGeoOffer: () => {
|
|
354
430
|
if (offer === undefined || state.geo !== undefined) return;
|
|
355
431
|
// A PUSH, like any other filter the person applies: Back takes the
|
|
356
432
|
// narrowing off again, which is the same promise every chip makes.
|
|
357
|
-
apply(patchSearchState(state, { geo: offer }));
|
|
433
|
+
apply(patchSearchState(state, { geo: offer }), historyOptions("geo"));
|
|
358
434
|
},
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
clearAll: () => apply(clearFilters(state)),
|
|
435
|
+
setLimit: (limit) =>
|
|
436
|
+
apply(patchSearchState(state, { limit }), historyOptions("limit")),
|
|
437
|
+
clearAll: () => apply(clearFilters(state), historyOptions("clear")),
|
|
438
|
+
// A keyset move REPLACES: it is scrolling, not a decision, and a push
|
|
439
|
+
// here used to make Back page backwards forever instead of leaving the
|
|
440
|
+
// results where the visitor actually was.
|
|
362
441
|
goToAnchor: (anchor, direction) =>
|
|
363
|
-
apply(patchSearchState(state, { anchor, direction })),
|
|
364
|
-
patch: (patch) => apply(patchSearchState(state, patch)),
|
|
442
|
+
apply(patchSearchState(state, { anchor, direction }), historyOptions("page")),
|
|
443
|
+
patch: (patch) => apply(patchSearchState(state, patch), historyOptions("patch")),
|
|
365
444
|
};
|
|
366
445
|
}, [parsed, commit, geoOffer]);
|
|
367
446
|
|
package/src/i18n/es.ts
CHANGED
|
@@ -93,6 +93,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
93
93
|
"search.facets.dictionary_empty": "Ningún valor coincide con esto",
|
|
94
94
|
"search.facets.dictionary_chosen": "Elegido",
|
|
95
95
|
"search.facets.dictionary_any": "Cualquiera",
|
|
96
|
+
"search.facets.dictionary_recommended": "Recomendados",
|
|
97
|
+
"search.facets.dictionary_all_values": "Todos los valores",
|
|
98
|
+
"search.facets.dictionary_done": "Listo",
|
|
96
99
|
"search.facets.all_filters": "Todos los filtros ({count})",
|
|
97
100
|
"search.facets.range_bounds": "de {min} a {max}",
|
|
98
101
|
"search.facets.popular_all": "Todos",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -183,6 +183,21 @@ export const SEARCH_I18N_KEYS = {
|
|
|
183
183
|
* diverge in every language with gender or case.
|
|
184
184
|
*/
|
|
185
185
|
facetsDictionaryAny: "search.facets.dictionary_any",
|
|
186
|
+
/**
|
|
187
|
+
* The heading over a dictionary sheet's first band: the busiest values of
|
|
188
|
+
* this axis, which is the EVIDENCE the answer carries and not an editorial
|
|
189
|
+
* pick. Distinct from `facetsPopularValues`, which names a block on the
|
|
190
|
+
* results page rather than a section inside one picker.
|
|
191
|
+
*/
|
|
192
|
+
facetsDictionaryRecommended: "search.facets.dictionary_recommended",
|
|
193
|
+
/** The heading over the rest of the dictionary sheet — everything the band
|
|
194
|
+
* did not take, alphabetically. Not `facetsShowAll`: that is a fold's
|
|
195
|
+
* button, this is a section a person scrolls into. */
|
|
196
|
+
facetsDictionaryAllValues: "search.facets.dictionary_all_values",
|
|
197
|
+
/** The dictionary sheet's commit. A multi-select sheet holds a draft and
|
|
198
|
+
* applies it once, so this word is the difference between keeping the
|
|
199
|
+
* ticks and dismissing them. */
|
|
200
|
+
facetsDictionaryDone: "search.facets.dictionary_done",
|
|
186
201
|
/**
|
|
187
202
|
* The tail of the rail, folded: how many groups are behind one control.
|
|
188
203
|
* `{count}` is a number in parentheses, not a counted noun, so this is not
|
|
@@ -563,6 +578,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
563
578
|
"search.facets.dictionary_empty": "No value matches this",
|
|
564
579
|
"search.facets.dictionary_chosen": "Chosen",
|
|
565
580
|
"search.facets.dictionary_any": "Any",
|
|
581
|
+
"search.facets.dictionary_recommended": "Recommended",
|
|
582
|
+
"search.facets.dictionary_all_values": "All values",
|
|
583
|
+
"search.facets.dictionary_done": "Done",
|
|
566
584
|
"search.facets.all_filters": "All filters ({count})",
|
|
567
585
|
"search.facets.range_bounds": "from {min} to {max}",
|
|
568
586
|
"search.facets.popular_all": "All",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -118,6 +118,9 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
118
118
|
"search.facets.dictionary_empty": "Такого значения здесь нет",
|
|
119
119
|
"search.facets.dictionary_chosen": "Выбрано",
|
|
120
120
|
"search.facets.dictionary_any": "Любая",
|
|
121
|
+
"search.facets.dictionary_recommended": "Рекомендуемые",
|
|
122
|
+
"search.facets.dictionary_all_values": "Все варианты",
|
|
123
|
+
"search.facets.dictionary_done": "Готово",
|
|
121
124
|
"search.facets.all_filters": "Все фильтры ({count})",
|
|
122
125
|
"search.facets.range_bounds": "от {min} до {max}",
|
|
123
126
|
"search.facets.popular_all": "Все",
|
package/src/index.ts
CHANGED
|
@@ -102,6 +102,7 @@ export type {
|
|
|
102
102
|
SearchStateIssue,
|
|
103
103
|
SearchStateIssueCode,
|
|
104
104
|
SearchStatePatch,
|
|
105
|
+
WriteSearchStateDefaults,
|
|
105
106
|
} from "./state/urlState.js";
|
|
106
107
|
|
|
107
108
|
export {
|
|
@@ -185,12 +186,15 @@ export { useRankingDisclosure, useSearchQuery, useSuggest } from "./model/querie
|
|
|
185
186
|
// ── headless (renderless components) ─────────────────────────────────────────
|
|
186
187
|
export { SearchProvider } from "./headless/SearchProvider.js";
|
|
187
188
|
export {
|
|
189
|
+
DEFAULT_HISTORY_MODE,
|
|
188
190
|
SearchStateProvider,
|
|
189
191
|
useFacetKeys,
|
|
190
192
|
usePublishFacetKeys,
|
|
191
193
|
useSearchState,
|
|
192
194
|
} from "./headless/SearchStateProvider.js";
|
|
193
195
|
export type {
|
|
196
|
+
HistoryMode,
|
|
197
|
+
SearchHistoryKind,
|
|
194
198
|
SearchParamsAdapter,
|
|
195
199
|
SearchStateBag,
|
|
196
200
|
SearchStateProviderProps,
|
package/src/state/urlState.ts
CHANGED
|
@@ -462,6 +462,26 @@ function optional<K extends string, V>(
|
|
|
462
462
|
return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
+
/**
|
|
466
|
+
* The defaults {@link writeSearchState} may omit — the other half of
|
|
467
|
+
* {@link ParseSearchStateOptions}.
|
|
468
|
+
*
|
|
469
|
+
* A value equal to its default is written back by {@link parseSearchState}
|
|
470
|
+
* whether or not the URL states it, so writing it too is pure noise: it is
|
|
471
|
+
* how `?type=listing` ended up in every address on a host with exactly one
|
|
472
|
+
* doc type, and how a page-size default rode along in a link that never
|
|
473
|
+
* touched it. Omitting it costs nothing on the read side — the same default
|
|
474
|
+
* fills the gap — and the address stops asserting a fact nobody chose.
|
|
475
|
+
*/
|
|
476
|
+
export interface WriteSearchStateDefaults {
|
|
477
|
+
/** Matches {@link ParseSearchStateOptions.defaultType}. */
|
|
478
|
+
readonly defaultType?: string;
|
|
479
|
+
/** Matches {@link ParseSearchStateOptions.defaultSort}. */
|
|
480
|
+
readonly defaultSort?: string;
|
|
481
|
+
/** Matches {@link ParseSearchStateOptions.defaultLimit}. */
|
|
482
|
+
readonly defaultLimit?: number;
|
|
483
|
+
}
|
|
484
|
+
|
|
465
485
|
/**
|
|
466
486
|
* State → URL.
|
|
467
487
|
*
|
|
@@ -473,11 +493,18 @@ function optional<K extends string, V>(
|
|
|
473
493
|
* `keys` is the answer's short-key map: a filter is written as its
|
|
474
494
|
* `url_key` when the answer states one and as its slug otherwise, so the two
|
|
475
495
|
* halves of a round trip agree without either side chopping at a string.
|
|
496
|
+
*
|
|
497
|
+
* `defaults` is what lets `type`, `sort` and `limit` disappear from the
|
|
498
|
+
* address when they equal the value {@link parseSearchState} would have
|
|
499
|
+
* filled in anyway — see {@link WriteSearchStateDefaults}. Omitted, nothing
|
|
500
|
+
* changes: every one of the three is written whenever the state carries it,
|
|
501
|
+
* exactly as before this existed.
|
|
476
502
|
*/
|
|
477
503
|
export function writeSearchState(
|
|
478
504
|
state: SearchQueryState,
|
|
479
505
|
base?: URLSearchParams,
|
|
480
|
-
keys?: FacetKeyMap
|
|
506
|
+
keys?: FacetKeyMap,
|
|
507
|
+
defaults?: WriteSearchStateDefaults
|
|
481
508
|
): URLSearchParams {
|
|
482
509
|
const next = new URLSearchParams();
|
|
483
510
|
|
|
@@ -488,7 +515,7 @@ export function writeSearchState(
|
|
|
488
515
|
}
|
|
489
516
|
}
|
|
490
517
|
|
|
491
|
-
next.set(SEARCH_PARAM.type, state.type);
|
|
518
|
+
if (state.type !== defaults?.defaultType) next.set(SEARCH_PARAM.type, state.type);
|
|
492
519
|
if (state.q.length > 0) next.set(SEARCH_PARAM.q, state.q);
|
|
493
520
|
if (state.lang !== undefined) next.set(SEARCH_PARAM.lang, state.lang);
|
|
494
521
|
if (state.category !== undefined) next.set(SEARCH_PARAM.category, state.category);
|
|
@@ -523,7 +550,9 @@ export function writeSearchState(
|
|
|
523
550
|
}
|
|
524
551
|
}
|
|
525
552
|
|
|
526
|
-
if (state.sort !== undefined
|
|
553
|
+
if (state.sort !== undefined && state.sort !== defaults?.defaultSort) {
|
|
554
|
+
next.set(SEARCH_PARAM.sort, state.sort);
|
|
555
|
+
}
|
|
527
556
|
if (state.facets !== undefined) {
|
|
528
557
|
next.set(
|
|
529
558
|
SEARCH_PARAM.facets,
|
|
@@ -532,7 +561,9 @@ export function writeSearchState(
|
|
|
532
561
|
}
|
|
533
562
|
if (state.anchor !== undefined) next.set(SEARCH_PARAM.anchor, state.anchor);
|
|
534
563
|
if (state.direction !== undefined) next.set(SEARCH_PARAM.direction, state.direction);
|
|
535
|
-
if (state.limit !== undefined
|
|
564
|
+
if (state.limit !== undefined && state.limit !== defaults?.defaultLimit) {
|
|
565
|
+
next.set(SEARCH_PARAM.limit, String(state.limit));
|
|
566
|
+
}
|
|
536
567
|
|
|
537
568
|
return next;
|
|
538
569
|
}
|