@stapel/search-react 0.26.0 → 0.28.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 +71 -0
- package/README.md +42 -5
- package/dist/default/FacetPanelPane.d.ts +13 -7
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +43 -19
- 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 +15 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +19 -8
- 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 +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.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 +0 -2
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -11
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +13 -14
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +0 -4
- 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 +6 -5
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetPanelPane.tsx +51 -25
- package/src/default/OtherCategoriesLine.tsx +52 -4
- package/src/default/SearchPage.tsx +43 -6
- package/src/default/SearchResultsPane.tsx +10 -1
- package/src/default/index.ts +1 -0
- package/src/headless/SearchStateProvider.tsx +99 -20
- package/src/i18n/es.ts +0 -4
- package/src/i18n/keys.ts +13 -16
- package/src/i18n/ru.ts +0 -8
- package/src/index.ts +4 -0
- package/src/state/urlState.ts +35 -4
|
@@ -30,9 +30,18 @@
|
|
|
30
30
|
* show a different, larger number, so the caption would be a lie one click
|
|
31
31
|
* later. Each entry therefore writes the `category` parameter of the search
|
|
32
32
|
* already on screen, keeping the query: press "Cars 12" and twelve results
|
|
33
|
-
* follow.
|
|
34
|
-
* an anchor.
|
|
33
|
+
* follow.
|
|
35
34
|
*
|
|
35
|
+
* Without {@link OtherCategoriesLineProps.categoryHref} that state change is
|
|
36
|
+
* ALL an entry does, so it is a plain `<button>` with no `href` — no address
|
|
37
|
+
* to hover, no "open in a new tab", nothing a crawler can follow. `categoryHref`
|
|
38
|
+
* turns the entry into a real `<a href>` without giving up the in-app
|
|
39
|
+
* narrowing: a plain click still rewrites the query in place (a full
|
|
40
|
+
* navigation would answer a different question than the one the count was
|
|
41
|
+
* counted for), while a modified click — the browser's own "open in a new
|
|
42
|
+
* tab/window" — is left alone and follows the address like any other link.
|
|
43
|
+
*
|
|
44
|
+
|
|
36
45
|
* ## Two rows on a phone, at most
|
|
37
46
|
*
|
|
38
47
|
* The cap is halved on the sheet surface ({@link OTHER_CATEGORIES_PHONE_LIMIT}),
|
|
@@ -70,6 +79,10 @@ export const OTHER_CATEGORIES_SLOT_MIN_HEIGHT = 24;
|
|
|
70
79
|
* drops the row rather than printing a number at a person. */
|
|
71
80
|
export type OtherCategoryNamer = (category: string) => string | undefined;
|
|
72
81
|
|
|
82
|
+
/** Resolves an id path to a real, navigable address. Returning `undefined`
|
|
83
|
+
* leaves the row's in-app narrowing as the only way to press it. */
|
|
84
|
+
export type OtherCategoryHrefResolver = (category: string) => string | undefined;
|
|
85
|
+
|
|
73
86
|
export interface OtherCategoriesLineProps {
|
|
74
87
|
/** How many entries before the fold (default {@link OTHER_CATEGORIES_LIMIT}). */
|
|
75
88
|
readonly limit?: number;
|
|
@@ -86,6 +99,24 @@ export interface OtherCategoriesLineProps {
|
|
|
86
99
|
* slug — and drops the rest, because "163 · 149" is not a sentence.
|
|
87
100
|
*/
|
|
88
101
|
readonly categoryName?: OtherCategoryNamer;
|
|
102
|
+
/**
|
|
103
|
+
* A real address for a category id path, when the host has one — a
|
|
104
|
+
* category page's own URL, most usefully.
|
|
105
|
+
*
|
|
106
|
+
* Without it every entry is a `<button>` with no `href`: it narrows the
|
|
107
|
+
* search on click, and nothing else — no "open in a new tab", no address
|
|
108
|
+
* to hover, nothing a crawler can follow. With it the entry becomes a real
|
|
109
|
+
* `<a href>` (a middle-click, a ctrl/cmd-click, "open in new tab" all work
|
|
110
|
+
* as they do for any link), while a plain click still narrows THIS search
|
|
111
|
+
* in place rather than leaving it — the whole reason the count beside a
|
|
112
|
+
* name is trustworthy is that it is a count for the query on screen, and a
|
|
113
|
+
* full navigation to the host's address would be answering a different
|
|
114
|
+
* question than the one the click asked.
|
|
115
|
+
*
|
|
116
|
+
* A row this returns nothing for keeps the in-app-only behaviour; the row
|
|
117
|
+
* is dropped only when it has no NAME, exactly as without this prop.
|
|
118
|
+
*/
|
|
119
|
+
readonly categoryHref?: OtherCategoryHrefResolver;
|
|
89
120
|
/** Skip the read entirely — mirrors `<SearchResultsPane enabled>`. */
|
|
90
121
|
readonly enabled?: boolean;
|
|
91
122
|
}
|
|
@@ -130,6 +161,7 @@ export function otherCategoriesCss(): string {
|
|
|
130
161
|
interface Entry {
|
|
131
162
|
readonly row: OtherCategoryRow;
|
|
132
163
|
readonly name: string;
|
|
164
|
+
readonly href?: string;
|
|
133
165
|
}
|
|
134
166
|
|
|
135
167
|
export function OtherCategoriesLine(
|
|
@@ -152,7 +184,9 @@ export function OtherCategoriesLine(
|
|
|
152
184
|
for (const row of bag.rows) {
|
|
153
185
|
const name =
|
|
154
186
|
props.categoryName?.(row.category) ?? row.name ?? otherCategoryLeaf(row.category);
|
|
155
|
-
if (name
|
|
187
|
+
if (name === undefined) continue;
|
|
188
|
+
const href = props.categoryHref?.(row.category);
|
|
189
|
+
entries.push(href !== undefined ? { row, name, href } : { row, name });
|
|
156
190
|
}
|
|
157
191
|
|
|
158
192
|
const shown = expanded ? entries : entries.slice(0, limit);
|
|
@@ -201,12 +235,26 @@ export function OtherCategoriesLine(
|
|
|
201
235
|
style={ENTRY}
|
|
202
236
|
data-testid="search-other-category"
|
|
203
237
|
data-category={entry.row.category}
|
|
238
|
+
{...(entry.href !== undefined ? { href: entry.href } : {})}
|
|
204
239
|
data-analytics="none"
|
|
205
240
|
data-analytics-reason="narrowing a search is a read, not a flow step"
|
|
206
241
|
aria-label={t(SEARCH_I18N_KEYS.otherCategoriesNarrow, {
|
|
207
242
|
name: entry.name,
|
|
208
243
|
})}
|
|
209
|
-
onClick={() => {
|
|
244
|
+
onClick={(event) => {
|
|
245
|
+
// A real `href` still narrows THIS search in place on a plain
|
|
246
|
+
// click — a full navigation would answer a different query
|
|
247
|
+
// than the one the count beside the name was counted for.
|
|
248
|
+
// Anything asking for a new tab/window (a modified click) is
|
|
249
|
+
// left to the browser, which is what makes the address real
|
|
250
|
+
// rather than decorative.
|
|
251
|
+
if (
|
|
252
|
+
entry.href !== undefined &&
|
|
253
|
+
(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
254
|
+
) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
event.preventDefault();
|
|
210
258
|
setCategory(entry.row.category);
|
|
211
259
|
}}
|
|
212
260
|
>
|
|
@@ -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";
|
|
@@ -277,6 +280,17 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
277
280
|
* page rather than the pane could not get the field at all.
|
|
278
281
|
*/
|
|
279
282
|
readonly dictionaryMode?: "field" | "inline";
|
|
283
|
+
/**
|
|
284
|
+
* How many groups the rail draws before the rest fold behind "All filters"
|
|
285
|
+
* — see {@link FacetPanelPaneProps.visibleGroups}.
|
|
286
|
+
*
|
|
287
|
+
* Defaulted PER LAYOUT, the same reasoning as `dictionaryMode`: 16 in the
|
|
288
|
+
* desktop COLUMN, where the rail sits on screen the whole time and the tail
|
|
289
|
+
* is a genuine scroll away; 8 in the phone SHEET, which is already a modal
|
|
290
|
+
* a person paid a tap to open — folding its tail behind a second control
|
|
291
|
+
* saves less than folding a column's does. Set it to override either.
|
|
292
|
+
*/
|
|
293
|
+
readonly visibleGroups?: number | null;
|
|
280
294
|
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
281
295
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
282
296
|
readonly skippedNotice?: boolean;
|
|
@@ -356,6 +370,9 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
356
370
|
/** What a category id path is CALLED, for the line above. The same question
|
|
357
371
|
* `categoryLabel` answers for the chip, asked once per row. */
|
|
358
372
|
readonly categoryName?: OtherCategoryNamer;
|
|
373
|
+
/** A real address for a category id path, for the line above — see
|
|
374
|
+
* {@link OtherCategoriesLineProps.categoryHref}. */
|
|
375
|
+
readonly categoryHref?: OtherCategoryHrefResolver;
|
|
359
376
|
/** What this surface calls its result list. See
|
|
360
377
|
* {@link SearchResultsPaneProps.heading}. */
|
|
361
378
|
readonly resultsHeading?: ReactNode;
|
|
@@ -439,6 +456,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
439
456
|
interface SearchPageBodyProps {
|
|
440
457
|
readonly renderCard?: SearchCardRenderer;
|
|
441
458
|
readonly dictionaryMode?: "field" | "inline";
|
|
459
|
+
readonly visibleGroups?: number | null;
|
|
442
460
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
443
461
|
readonly renderEmptyExits?: () => ReactNode;
|
|
444
462
|
readonly locale?: string;
|
|
@@ -456,6 +474,7 @@ interface SearchPageBodyProps {
|
|
|
456
474
|
readonly appliedChips?: boolean | "desktop";
|
|
457
475
|
readonly otherCategories?: boolean;
|
|
458
476
|
readonly categoryName?: OtherCategoryNamer;
|
|
477
|
+
readonly categoryHref?: OtherCategoryHrefResolver;
|
|
459
478
|
readonly resultsHeading?: ReactNode;
|
|
460
479
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
461
480
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
@@ -533,11 +552,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
533
552
|
const filtersEmpty =
|
|
534
553
|
facets.state.status === "ready" &&
|
|
535
554
|
facets.state.data.length === 0 &&
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
555
|
+
// `withheld` (groups the server counted and held back for covering too
|
|
556
|
+
// little of the result set) used to keep the column open so the panel
|
|
557
|
+
// could name how many. It no longer prints that sentence at all (D175,
|
|
558
|
+
// amended) — a reference catalogue says nothing in this case either — so
|
|
559
|
+
// zero groups plus zero of everything else below really is nothing to
|
|
560
|
+
// show.
|
|
541
561
|
facets.activeFilters === 0 &&
|
|
542
562
|
ranges.length === 0 &&
|
|
543
563
|
state.category === undefined &&
|
|
@@ -568,6 +588,16 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
568
588
|
// pinned to the port's floor sat on top of the last groups.
|
|
569
589
|
{ footerBar: "static" as const })}
|
|
570
590
|
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "inline" : "field")}
|
|
591
|
+
// `??` would treat an explicit `null` ("never fold") the same as
|
|
592
|
+
// "not set": `visibleGroups` uses `null` as a real value, unlike
|
|
593
|
+
// `dictionaryMode` above, so only `undefined` falls through.
|
|
594
|
+
visibleGroups={
|
|
595
|
+
props.visibleGroups !== undefined
|
|
596
|
+
? props.visibleGroups
|
|
597
|
+
: layout === "sheet"
|
|
598
|
+
? 8
|
|
599
|
+
: 16
|
|
600
|
+
}
|
|
571
601
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
572
602
|
{...(props.renderEmptyExits !== undefined
|
|
573
603
|
? { renderEmptyExits: props.renderEmptyExits }
|
|
@@ -636,6 +666,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
636
666
|
{...(props.categoryName !== undefined
|
|
637
667
|
? { categoryName: props.categoryName }
|
|
638
668
|
: {})}
|
|
669
|
+
{...(props.categoryHref !== undefined
|
|
670
|
+
? { categoryHref: props.categoryHref }
|
|
671
|
+
: {})}
|
|
639
672
|
{...(props.resultsHeading !== undefined
|
|
640
673
|
? { heading: props.resultsHeading }
|
|
641
674
|
: {})}
|
|
@@ -815,6 +848,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
815
848
|
appliedChips,
|
|
816
849
|
otherCategories,
|
|
817
850
|
categoryName,
|
|
851
|
+
categoryHref,
|
|
818
852
|
resultsHeading,
|
|
819
853
|
degradationNotice,
|
|
820
854
|
filtersLayout,
|
|
@@ -828,6 +862,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
828
862
|
resultsAction,
|
|
829
863
|
resultsHeadingLevel,
|
|
830
864
|
dictionaryMode,
|
|
865
|
+
visibleGroups,
|
|
831
866
|
mode,
|
|
832
867
|
...parseOptions
|
|
833
868
|
} = props;
|
|
@@ -838,6 +873,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
838
873
|
<SearchPageBody
|
|
839
874
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
840
875
|
{...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
|
|
876
|
+
{...(visibleGroups !== undefined ? { visibleGroups } : {})}
|
|
841
877
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
842
878
|
{...(locale !== undefined ? { locale } : {})}
|
|
843
879
|
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
@@ -854,6 +890,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
854
890
|
{...(appliedChips !== undefined ? { appliedChips } : {})}
|
|
855
891
|
{...(otherCategories !== undefined ? { otherCategories } : {})}
|
|
856
892
|
{...(categoryName !== undefined ? { categoryName } : {})}
|
|
893
|
+
{...(categoryHref !== undefined ? { categoryHref } : {})}
|
|
857
894
|
{...(resultsHeading !== undefined ? { resultsHeading } : {})}
|
|
858
895
|
{...(degradationNotice !== undefined ? { degradationNotice } : {})}
|
|
859
896
|
{...(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
|
@@ -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
|
@@ -75,10 +75,6 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
75
75
|
"Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
|
|
76
76
|
"search.facets.skipped":
|
|
77
77
|
"Estos filtros no se contaron para esta búsqueda: {slugs}",
|
|
78
|
-
"search.facets.withheld.one":
|
|
79
|
-
"{count} filtro se aplica a muy pocos de estos resultados",
|
|
80
|
-
"search.facets.withheld.other":
|
|
81
|
-
"{count} filtros se aplican a muy pocos de estos resultados",
|
|
82
78
|
"search.facets.not_counted": "sin contar",
|
|
83
79
|
"search.facets.drill_down_hint":
|
|
84
80
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -111,22 +111,24 @@ export const SEARCH_I18N_KEYS = {
|
|
|
111
111
|
facetsTitle: "search.facets.title",
|
|
112
112
|
facetsLoading: "search.facets.loading",
|
|
113
113
|
facetsLoadFailed: "search.facets.load_failed",
|
|
114
|
+
/**
|
|
115
|
+
* "This search offers no filters" — the ONE sentence the empty arm may
|
|
116
|
+
* print, and only when the rail draws nothing else either (no price, no
|
|
117
|
+
* location, no partition — see `FacetsEmptyArm`'s `hasOtherDrawable`).
|
|
118
|
+
*
|
|
119
|
+
* D175, amended: this used to be one of TWO sentences, the other keyed on
|
|
120
|
+
* `facet_meta.withheld` ("N filters apply to too few of these results").
|
|
121
|
+
* A reference catalogue was checked for the same case and says neither —
|
|
122
|
+
* it leaves the filters visible with low counts and no explanation of why
|
|
123
|
+
* some are thin. `withheld` (and `skipped`) now suppress this sentence
|
|
124
|
+
* instead of replacing it; a test that needs the count reads
|
|
125
|
+
* `data-withheld` on the empty arm, which carries no text.
|
|
126
|
+
*/
|
|
114
127
|
facetsEmpty: "search.facets.empty",
|
|
115
128
|
facetsClear: "search.facets.clear",
|
|
116
129
|
facetsClearAll: "search.facets.clear_all",
|
|
117
130
|
facetsApproximate: "search.facets.approximate",
|
|
118
131
|
facetsSkipped: "search.facets.skipped",
|
|
119
|
-
/**
|
|
120
|
-
* "N filters apply to too few of these results" — the sentence that
|
|
121
|
-
* REPLACES {@link facetsEmpty} whenever `facet_meta.withheld` is not
|
|
122
|
-
* empty. A PLURAL FAMILY: it counts a noun in words.
|
|
123
|
-
*
|
|
124
|
-
* D175: a branch category holding 46 phones that all carry a manufacturer
|
|
125
|
-
* printed "this search offers no filters". The groups were counted and
|
|
126
|
-
* then withheld for covering too little of the set — so "there are none"
|
|
127
|
-
* was false, and the honest sentence is this one, with the number in it.
|
|
128
|
-
*/
|
|
129
|
-
facetsWithheld: "search.facets.withheld",
|
|
130
132
|
/** Shown INSTEAD of a count for a slug the server did not count. */
|
|
131
133
|
facetsNotCounted: "search.facets.not_counted",
|
|
132
134
|
facetsDrillDownHint: "search.facets.drill_down_hint",
|
|
@@ -471,7 +473,6 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
471
473
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
472
474
|
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
473
475
|
SEARCH_I18N_KEYS.facetsMatchCount,
|
|
474
|
-
SEARCH_I18N_KEYS.facetsWithheld,
|
|
475
476
|
];
|
|
476
477
|
|
|
477
478
|
/**
|
|
@@ -544,10 +545,6 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
544
545
|
"Counts are approximate — there were too many candidates to count them all.",
|
|
545
546
|
"search.facets.skipped":
|
|
546
547
|
"These filters were not counted for this search: {slugs}",
|
|
547
|
-
"search.facets.withheld.one":
|
|
548
|
-
"{count} filter applies to too few of these results",
|
|
549
|
-
"search.facets.withheld.other":
|
|
550
|
-
"{count} filters apply to too few of these results",
|
|
551
548
|
"search.facets.not_counted": "not counted",
|
|
552
549
|
"search.facets.drill_down_hint":
|
|
553
550
|
"Each count is what you would get by choosing that value instead of the one you have.",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -100,14 +100,6 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
100
100
|
"Счётчики приблизительные — подходящих объявлений слишком много, чтобы пересчитать все.",
|
|
101
101
|
"search.facets.skipped":
|
|
102
102
|
"Эти фильтры для текущего поиска не посчитаны: {slugs}",
|
|
103
|
-
"search.facets.withheld.one":
|
|
104
|
-
"Ещё {count} фильтр подходит слишком малой части результатов",
|
|
105
|
-
"search.facets.withheld.few":
|
|
106
|
-
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
107
|
-
"search.facets.withheld.many":
|
|
108
|
-
"Ещё {count} фильтров подходят слишком малой части результатов",
|
|
109
|
-
"search.facets.withheld.other":
|
|
110
|
-
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
111
103
|
"search.facets.not_counted": "не посчитано",
|
|
112
104
|
"search.facets.drill_down_hint":
|
|
113
105
|
"Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
|
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
|
}
|