@stapel/search-react 0.27.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +34 -0
  3. package/dist/default/OtherCategoriesLine.d.ts +21 -0
  4. package/dist/default/OtherCategoriesLine.d.ts.map +1 -1
  5. package/dist/default/OtherCategoriesLine.js +28 -6
  6. package/dist/default/OtherCategoriesLine.js.map +1 -1
  7. package/dist/default/SearchPage.d.ts +4 -1
  8. package/dist/default/SearchPage.d.ts.map +1 -1
  9. package/dist/default/SearchPage.js +4 -2
  10. package/dist/default/SearchPage.js.map +1 -1
  11. package/dist/default/SearchResultsPane.d.ts +4 -1
  12. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  13. package/dist/default/SearchResultsPane.js +2 -0
  14. package/dist/default/SearchResultsPane.js.map +1 -1
  15. package/dist/default/index.d.ts +1 -1
  16. package/dist/default/index.d.ts.map +1 -1
  17. package/dist/default/index.js.map +1 -1
  18. package/dist/headless/SearchStateProvider.d.ts +38 -1
  19. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  20. package/dist/headless/SearchStateProvider.js +70 -19
  21. package/dist/headless/SearchStateProvider.js.map +1 -1
  22. package/dist/index.d.ts +3 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/state/urlState.d.ts +26 -1
  27. package/dist/state/urlState.d.ts.map +1 -1
  28. package/dist/state/urlState.js +13 -4
  29. package/dist/state/urlState.js.map +1 -1
  30. package/llms.txt +1 -1
  31. package/manifest.json +6 -2
  32. package/nav-manifest.json +1 -1
  33. package/package.json +7 -7
  34. package/src/analytics/generated/events.json +1 -1
  35. package/src/default/OtherCategoriesLine.tsx +52 -4
  36. package/src/default/SearchPage.tsx +13 -1
  37. package/src/default/SearchResultsPane.tsx +10 -1
  38. package/src/default/index.ts +1 -0
  39. package/src/headless/SearchStateProvider.tsx +99 -20
  40. package/src/index.ts +4 -0
  41. 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. That is a state change, so each entry is a real `<button>` and not
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 !== undefined) entries.push({ row, 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 { OtherCategoryNamer } from "./OtherCategoriesLine.js";
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";
@@ -367,6 +370,9 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
367
370
  /** What a category id path is CALLED, for the line above. The same question
368
371
  * `categoryLabel` answers for the chip, asked once per row. */
369
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;
370
376
  /** What this surface calls its result list. See
371
377
  * {@link SearchResultsPaneProps.heading}. */
372
378
  readonly resultsHeading?: ReactNode;
@@ -468,6 +474,7 @@ interface SearchPageBodyProps {
468
474
  readonly appliedChips?: boolean | "desktop";
469
475
  readonly otherCategories?: boolean;
470
476
  readonly categoryName?: OtherCategoryNamer;
477
+ readonly categoryHref?: OtherCategoryHrefResolver;
471
478
  readonly resultsHeading?: ReactNode;
472
479
  readonly degradationNotice?: DegradationNoticeVariant;
473
480
  readonly filtersLayout?: SearchFiltersLayout;
@@ -659,6 +666,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
659
666
  {...(props.categoryName !== undefined
660
667
  ? { categoryName: props.categoryName }
661
668
  : {})}
669
+ {...(props.categoryHref !== undefined
670
+ ? { categoryHref: props.categoryHref }
671
+ : {})}
662
672
  {...(props.resultsHeading !== undefined
663
673
  ? { heading: props.resultsHeading }
664
674
  : {})}
@@ -838,6 +848,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
838
848
  appliedChips,
839
849
  otherCategories,
840
850
  categoryName,
851
+ categoryHref,
841
852
  resultsHeading,
842
853
  degradationNotice,
843
854
  filtersLayout,
@@ -879,6 +890,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
879
890
  {...(appliedChips !== undefined ? { appliedChips } : {})}
880
891
  {...(otherCategories !== undefined ? { otherCategories } : {})}
881
892
  {...(categoryName !== undefined ? { categoryName } : {})}
893
+ {...(categoryHref !== undefined ? { categoryHref } : {})}
882
894
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
883
895
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
884
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 { OtherCategoryNamer } from "./OtherCategoriesLine.js";
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
 
@@ -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
 
@@ -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; an adapter that ignores it breaks the Back button.
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
- // Typing replaces rather than pushes: one history entry per letter
341
- // would make Back useless, which is the control the spec's acceptance
342
- // leans on for "Back removes the last filter".
343
- setText: (q) => apply(patchSearchState(state, { q }), { replace: true }),
344
- setSort: (sort) => apply(patchSearchState(state, { sort })),
345
- setCategory: (category) => apply(patchSearchState(state, { category })),
346
- setLanguage: (lang) => apply(patchSearchState(state, { lang })),
347
- toggleFilter: (slug, value) => apply(toggleFilterValue(state, slug, value)),
348
- setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
349
- setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
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
- // A page size is a preference, not a step through the results.
360
- setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
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/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,
@@ -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) next.set(SEARCH_PARAM.sort, state.sort);
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) next.set(SEARCH_PARAM.limit, String(state.limit));
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
  }