@stapel/search-react 0.20.0 → 0.22.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 (98) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +65 -2
  3. package/dist/api/generated/schema.d.ts +96 -4
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/api/types.d.ts +68 -6
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/FacetGroupControl.d.ts +34 -4
  9. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  10. package/dist/default/FacetGroupControl.js +112 -7
  11. package/dist/default/FacetGroupControl.js.map +1 -1
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +27 -1
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/LocationSummaryLine.d.ts +3 -0
  16. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  17. package/dist/default/LocationSummaryLine.js +55 -8
  18. package/dist/default/LocationSummaryLine.js.map +1 -1
  19. package/dist/default/PartitionChips.d.ts +23 -0
  20. package/dist/default/PartitionChips.d.ts.map +1 -0
  21. package/dist/default/PartitionChips.js +94 -0
  22. package/dist/default/PartitionChips.js.map +1 -0
  23. package/dist/default/PopularValues.d.ts +58 -0
  24. package/dist/default/PopularValues.d.ts.map +1 -0
  25. package/dist/default/PopularValues.js +46 -0
  26. package/dist/default/PopularValues.js.map +1 -0
  27. package/dist/default/SearchPage.d.ts.map +1 -1
  28. package/dist/default/SearchPage.js +5 -0
  29. package/dist/default/SearchPage.js.map +1 -1
  30. package/dist/default/index.d.ts +5 -1
  31. package/dist/default/index.d.ts.map +1 -1
  32. package/dist/default/index.js +6 -1
  33. package/dist/default/index.js.map +1 -1
  34. package/dist/headless/FacetPanel.d.ts +29 -1
  35. package/dist/headless/FacetPanel.d.ts.map +1 -1
  36. package/dist/headless/FacetPanel.js +14 -0
  37. package/dist/headless/FacetPanel.js.map +1 -1
  38. package/dist/headless/SearchStateProvider.d.ts +15 -0
  39. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  40. package/dist/headless/SearchStateProvider.js +38 -3
  41. package/dist/headless/SearchStateProvider.js.map +1 -1
  42. package/dist/headless/useFacetLabels.js +7 -7
  43. package/dist/headless/useFacetLabels.js.map +1 -1
  44. package/dist/i18n/es.d.ts.map +1 -1
  45. package/dist/i18n/es.js +12 -0
  46. package/dist/i18n/es.js.map +1 -1
  47. package/dist/i18n/keys.d.ts +72 -0
  48. package/dist/i18n/keys.d.ts.map +1 -1
  49. package/dist/i18n/keys.js +86 -0
  50. package/dist/i18n/keys.js.map +1 -1
  51. package/dist/i18n/ru.d.ts.map +1 -1
  52. package/dist/i18n/ru.js +14 -0
  53. package/dist/i18n/ru.js.map +1 -1
  54. package/dist/index.d.ts +4 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +2 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/state/degradations.d.ts +9 -0
  59. package/dist/state/degradations.d.ts.map +1 -1
  60. package/dist/state/degradations.js +13 -0
  61. package/dist/state/degradations.js.map +1 -1
  62. package/dist/state/facets.d.ts +29 -6
  63. package/dist/state/facets.d.ts.map +1 -1
  64. package/dist/state/facets.js +120 -16
  65. package/dist/state/facets.js.map +1 -1
  66. package/dist/state/translit.d.ts +58 -0
  67. package/dist/state/translit.d.ts.map +1 -0
  68. package/dist/state/translit.js +115 -0
  69. package/dist/state/translit.js.map +1 -0
  70. package/dist/state/urlState.d.ts +26 -1
  71. package/dist/state/urlState.d.ts.map +1 -1
  72. package/dist/state/urlState.js +43 -17
  73. package/dist/state/urlState.js.map +1 -1
  74. package/llms.txt +4 -2
  75. package/manifest.json +49 -2
  76. package/nav-manifest.json +1 -1
  77. package/package.json +8 -8
  78. package/src/analytics/generated/events.json +1 -1
  79. package/src/api/generated/schema.ts +96 -4
  80. package/src/api/types.ts +77 -5
  81. package/src/default/FacetGroupControl.tsx +213 -8
  82. package/src/default/FacetPanelPane.tsx +36 -1
  83. package/src/default/LocationSummaryLine.tsx +114 -53
  84. package/src/default/PartitionChips.tsx +161 -0
  85. package/src/default/PopularValues.tsx +151 -0
  86. package/src/default/SearchPage.tsx +5 -0
  87. package/src/default/index.ts +15 -0
  88. package/src/headless/FacetPanel.tsx +49 -1
  89. package/src/headless/SearchStateProvider.tsx +59 -3
  90. package/src/headless/useFacetLabels.ts +7 -7
  91. package/src/i18n/es.ts +16 -0
  92. package/src/i18n/keys.ts +91 -0
  93. package/src/i18n/ru.ts +20 -0
  94. package/src/index.ts +12 -0
  95. package/src/state/degradations.ts +14 -0
  96. package/src/state/facets.ts +154 -28
  97. package/src/state/translit.ts +113 -0
  98. package/src/state/urlState.ts +72 -13
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `<PartitionChips>` — the children of a `chips` category, as one row of
3
+ * single-select chips.
4
+ *
5
+ * A partition is a category whose children are not subcategories but one
6
+ * template split by a value their names express: buy / sell / let / rent,
7
+ * new / used, for boys / for girls. They keep their
8
+ * ids, their paths and their URLs — a listing still lands on a child — and
9
+ * only the PRESENTATION changes: the parent draws a feed, and the children
10
+ * are a choice above it rather than a grid of tiles the visitor has to pass
11
+ * through.
12
+ *
13
+ * Which categories are a partition is not decided here and not decided by
14
+ * this pair: `children_as` is a stored, derivable field on the category, and
15
+ * the storefront hands this component the children it resolved. What this
16
+ * component owns is that the choice is SINGLE-select and that "all" — the
17
+ * parent, unnarrowed — is one of the options rather than a way of clearing
18
+ * the others.
19
+ *
20
+ * ── Why a radiogroup and not a row of toggles ─────────────────────────────
21
+ *
22
+ * Because exactly one of them is true at a time, and `aria-pressed` buttons
23
+ * say the opposite: they announce a set of independent switches, so a screen
24
+ * reader user hears no reason why pressing one released another. A radiogroup
25
+ * with roving tabindex is the pattern for "one of these": Tab reaches the row
26
+ * once and lands on the chosen chip, the arrow keys move along it, and the
27
+ * group's own name says what is being chosen.
28
+ */
29
+ import { useRef } from "react";
30
+ import type {
31
+ CSSProperties,
32
+ KeyboardEvent as ReactKeyboardEvent,
33
+ ReactElement,
34
+ ReactNode,
35
+ } from "react";
36
+ import { Button } from "antd";
37
+ import { useT } from "@stapel/core";
38
+ import { radii, spacing } from "@stapel/tokens";
39
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
40
+
41
+ /** One child of a partitioned category. `path` is the slash-joined id path
42
+ * the `category` parameter takes — the same string `SearchQueryState.category`
43
+ * carries, so a host never rebuilds it from ids. */
44
+ export interface PartitionChild {
45
+ readonly id: number | string;
46
+ readonly path: string;
47
+ readonly name: string;
48
+ }
49
+
50
+ export interface PartitionChipsProps {
51
+ /** The children, in the order the catalogue declares them. */
52
+ readonly items: readonly PartitionChild[];
53
+ /** The chosen child's `path`, or `null` for the parent itself. CONTROLLED:
54
+ * this row keeps no state, because the choice is a `category` in the URL. */
55
+ readonly value: string | null;
56
+ readonly onChange: (path: string | null) => void;
57
+ /** The first chip's label. Defaults to `search.partition.all`. */
58
+ readonly allLabel?: ReactNode;
59
+ /** The row's accessible name. Defaults to `search.partition.label`. */
60
+ readonly label?: string;
61
+ }
62
+
63
+ const CHIP: CSSProperties = { borderRadius: radii.full };
64
+
65
+ const ROW: CSSProperties = {
66
+ display: "flex",
67
+ flexWrap: "wrap",
68
+ gap: spacing[2],
69
+ };
70
+
71
+ /** The row's cells, as `[value, label]` — the parent first, then the
72
+ * children in catalogue order. */
73
+ function cells(
74
+ items: readonly PartitionChild[],
75
+ allLabel: ReactNode
76
+ ): readonly (readonly [string | null, ReactNode])[] {
77
+ return [
78
+ [null, allLabel] as const,
79
+ ...items.map((item) => [item.path, item.name] as const),
80
+ ];
81
+ }
82
+
83
+ export function PartitionChips(props: PartitionChipsProps): ReactElement {
84
+ const t = useT();
85
+ const row = useRef<HTMLDivElement>(null);
86
+ const options = cells(
87
+ props.items,
88
+ props.allLabel ?? t(SEARCH_I18N_KEYS.partitionAll)
89
+ );
90
+
91
+ /**
92
+ * Arrow keys move the choice AND the focus, which is what a radiogroup
93
+ * does: in a single-select row the focused option is the selected one, so
94
+ * moving focus without choosing would leave the two disagreeing.
95
+ */
96
+ const onKeyDown =
97
+ (index: number) =>
98
+ (event: ReactKeyboardEvent): void => {
99
+ const step =
100
+ event.key === "ArrowRight" || event.key === "ArrowDown"
101
+ ? 1
102
+ : event.key === "ArrowLeft" || event.key === "ArrowUp"
103
+ ? -1
104
+ : event.key === "Home"
105
+ ? -index
106
+ : event.key === "End"
107
+ ? options.length - 1 - index
108
+ : 0;
109
+ if (step === 0) return;
110
+ event.preventDefault();
111
+ const next = (index + step + options.length) % options.length;
112
+ const cell = options[next];
113
+ if (cell === undefined) return;
114
+ props.onChange(cell[0]);
115
+ const buttons = row.current?.querySelectorAll<HTMLElement>('[role="radio"]');
116
+ buttons?.[next]?.focus();
117
+ };
118
+
119
+ // A `value` naming no cell (a link into a child that has since moved) must
120
+ // still leave the row reachable by Tab, so the roving stop falls back to the
121
+ // parent chip rather than vanishing.
122
+ const active = options.findIndex(([value]) => value === props.value);
123
+ const stop = active >= 0 ? active : 0;
124
+
125
+ return (
126
+ <div
127
+ style={ROW}
128
+ ref={row}
129
+ role="radiogroup"
130
+ aria-label={props.label ?? t(SEARCH_I18N_KEYS.partitionLabel)}
131
+ data-testid="partition-chips"
132
+ >
133
+ {options.map(([value, label], index) => {
134
+ const selected = value === props.value;
135
+ return (
136
+ <Button
137
+ key={value ?? "__all__"}
138
+ size="small"
139
+ shape="round"
140
+ type={selected ? "primary" : "default"}
141
+ role="radio"
142
+ aria-checked={selected}
143
+ // Roving tabindex: the row is ONE Tab stop and it lands on the
144
+ // chosen chip, not on the first of eight.
145
+ tabIndex={index === stop ? 0 : -1}
146
+ style={CHIP}
147
+ data-testid={`partition-chip-${value ?? "all"}`}
148
+ data-analytics="none"
149
+ data-analytics-reason="choosing a section is a read, not a flow step"
150
+ onKeyDown={onKeyDown(index)}
151
+ onClick={() => {
152
+ props.onChange(value);
153
+ }}
154
+ >
155
+ {label}
156
+ </Button>
157
+ );
158
+ })}
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `<PopularValues>` — the busiest values of one facet, printed as words a
3
+ * person can click, above the results.
4
+ *
5
+ * A dictionary facet in the rail answers "narrow this list"; it does not
6
+ * answer "what is IN this category". On a feed page of a cars leaf the second
7
+ * question is the one a visitor arrives with, and its answer is already in the
8
+ * envelope: `Toyota 802` is a bucket and its count. Printed as a multi-column
9
+ * block it is a table of contents for the category — the eleven makes that
10
+ * account for most of it, in one glance, each one a filter.
11
+ *
12
+ * ── What it is NOT ────────────────────────────────────────────────────────
13
+ *
14
+ * Not a replacement for the facet control: it shows the busy head of ONE
15
+ * group and says so with a link into the whole thing (`onShowAll`). Not a
16
+ * second source of counts either — the numbers are the answer's own
17
+ * drill-down counts, the same ones the checkbox rows carry, so a value cannot
18
+ * read `802` here and `93` in the panel.
19
+ *
20
+ * ── Hidden on a phone by a PROP ───────────────────────────────────────────
21
+ *
22
+ * `hidden` rather than a media query inside, because whether a 390px screen
23
+ * has room for a block of forty links is a decision about the PAGE, and the
24
+ * page is the storefront's. A component that hid itself below some width of
25
+ * its own choosing would take that decision away from the only surface that
26
+ * knows what else is on screen — and would still render the DOM, which is
27
+ * what `display: none` costs a screen reader.
28
+ */
29
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
30
+ import { Button, Flex, Typography } from "antd";
31
+ import { useT } from "@stapel/core";
32
+ import { spacing } from "@stapel/tokens";
33
+ import type { FacetGroup, FacetOption } from "../state/facets.js";
34
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
35
+
36
+ /** How many values the block prints before the link into the full control. */
37
+ export const POPULAR_VALUES_LIMIT = 12;
38
+
39
+ /** How many columns the list flows into. Three fills a desktop content column
40
+ * without turning a make into a two-line wrap. */
41
+ export const POPULAR_VALUES_COLUMNS = 3;
42
+
43
+ /** A value with no evidence behind it is not a popular value. Uncounted
44
+ * options carry `count: null` and are dropped here rather than printed with a
45
+ * blank where the number belongs — the block IS the numbers. */
46
+ function hasEvidence(option: FacetOption): boolean {
47
+ return option.count !== null && option.count > 0;
48
+ }
49
+
50
+ /** The busiest values of the group, count-descending, capped. */
51
+ export function popularOptions(
52
+ group: FacetGroup,
53
+ limit: number = POPULAR_VALUES_LIMIT
54
+ ): readonly FacetOption[] {
55
+ return [...group.options.filter(hasEvidence)]
56
+ .sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
57
+ .slice(0, limit);
58
+ }
59
+
60
+ const ROW: CSSProperties = {
61
+ // `break-inside` keeps a value and its count on one line when the browser
62
+ // decides where the column ends.
63
+ breakInside: "avoid",
64
+ display: "flex",
65
+ gap: spacing[2],
66
+ alignItems: "baseline",
67
+ };
68
+
69
+ export interface PopularValuesProps {
70
+ /** The group to print — normally the first `ref_select` of the plan. */
71
+ readonly group: FacetGroup;
72
+ /** Apply one value. Same signature as the panel's `toggle`, so a host can
73
+ * hand the facet bag's own function straight in. */
74
+ readonly onApply: (slug: string, value: string) => void;
75
+ /** Draw nothing. The phone, decided by the page — see the module note. */
76
+ readonly hidden?: boolean;
77
+ /** How many values. Default {@link POPULAR_VALUES_LIMIT}. */
78
+ readonly limit?: number;
79
+ /** How many columns. Default {@link POPULAR_VALUES_COLUMNS}. */
80
+ readonly columns?: number;
81
+ /** The block's heading. Defaults to the group's own label; `null` draws
82
+ * none, for a surface that has already named the axis. */
83
+ readonly heading?: ReactNode;
84
+ /** Open the full control. Absent draws no link — a link that goes nowhere
85
+ * is worse than a block that stops. */
86
+ readonly onShowAll?: () => void;
87
+ }
88
+
89
+ export function PopularValues(props: PopularValuesProps): ReactElement | null {
90
+ const t = useT();
91
+ const { group } = props;
92
+ if (props.hidden === true) return null;
93
+ const options = popularOptions(group, props.limit ?? POPULAR_VALUES_LIMIT);
94
+ if (options.length === 0) return null;
95
+
96
+ return (
97
+ <Flex
98
+ vertical
99
+ gap={spacing[2]}
100
+ data-testid={`popular-values-${group.slug}`}
101
+ data-label-source={group.labelSource}
102
+ >
103
+ {props.heading !== null && (
104
+ <Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
105
+ )}
106
+ <div
107
+ style={{
108
+ columnCount: props.columns ?? POPULAR_VALUES_COLUMNS,
109
+ columnGap: spacing[4],
110
+ }}
111
+ >
112
+ {options.map((option) => (
113
+ <div key={option.value} style={ROW}>
114
+ <Button
115
+ type="link"
116
+ size="small"
117
+ style={{ paddingInline: 0, height: "auto" }}
118
+ data-testid={`popular-value-${group.slug}-${option.value}`}
119
+ data-analytics="none"
120
+ data-analytics-reason="a filter is a read, not a flow step"
121
+ onClick={() => {
122
+ props.onApply(group.slug, option.value);
123
+ }}
124
+ >
125
+ {option.label}
126
+ </Button>
127
+ <Typography.Text
128
+ type="secondary"
129
+ data-testid={`popular-count-${group.slug}-${option.value}`}
130
+ >
131
+ {option.count}
132
+ </Typography.Text>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ {props.onShowAll !== undefined && (
137
+ <Button
138
+ type="link"
139
+ size="small"
140
+ style={{ alignSelf: "flex-start", paddingInline: 0 }}
141
+ data-testid={`popular-all-${group.slug}`}
142
+ data-analytics="none"
143
+ data-analytics-reason="opening a filter control is a read, not a flow step"
144
+ onClick={props.onShowAll}
145
+ >
146
+ {t(SEARCH_I18N_KEYS.facetsPopularAll)}
147
+ </Button>
148
+ )}
149
+ </Flex>
150
+ );
151
+ }
@@ -426,6 +426,11 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
426
426
  const filtersEmpty =
427
427
  facets.state.status === "ready" &&
428
428
  facets.state.data.length === 0 &&
429
+ // Zero groups is not zero filters: `withheld` names groups the server
430
+ // counted and held back for covering too little of the result set, and
431
+ // the panel's job is to say how many (D175). Skipping the column would
432
+ // put that sentence nowhere.
433
+ facets.withheld.length === 0 &&
429
434
  facets.activeFilters === 0 &&
430
435
  ranges.length === 0 &&
431
436
  state.category === undefined &&
@@ -72,6 +72,8 @@ export {
72
72
  FacetGroupControl,
73
73
  facetGroupShape,
74
74
  facetOptionNodes,
75
+ isDictionaryFacet,
76
+ FACET_DICTIONARY_THRESHOLD,
75
77
  FACET_VISIBLE_OPTIONS,
76
78
  } from "./FacetGroupControl.js";
77
79
  export type {
@@ -80,6 +82,19 @@ export type {
80
82
  FacetOptionNode,
81
83
  } from "./FacetGroupControl.js";
82
84
 
85
+ // ── the browse surfaces a storefront PLACES (this pair does not lay them
86
+ // out: where a popular-values block or a partition row belongs on a
87
+ // category page is the page's decision) ──────────────────────────────────
88
+ export {
89
+ PopularValues,
90
+ popularOptions,
91
+ POPULAR_VALUES_COLUMNS,
92
+ POPULAR_VALUES_LIMIT,
93
+ } from "./PopularValues.js";
94
+ export type { PopularValuesProps } from "./PopularValues.js";
95
+ export { PartitionChips } from "./PartitionChips.js";
96
+ export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
97
+
83
98
  export { FacetPanelPane } from "./FacetPanelPane.js";
84
99
  export type {
85
100
  FacetPanelPaneProps,
@@ -2,9 +2,15 @@ import type { ReactNode } from "react";
2
2
  import { loadStateFromQuery, mapLoad, useT } from "@stapel/core";
3
3
  import type { LoadState } from "@stapel/core";
4
4
  import type { FeatureDef } from "@stapel/attributes-react";
5
- import type { FacetMeta, SearchRange } from "../api/types.js";
5
+ import type {
6
+ FacetCategoryCount,
7
+ FacetMeta,
8
+ FacetWithheldGroup,
9
+ SearchRange,
10
+ } from "../api/types.js";
6
11
  import { useSearchQuery } from "../model/queries.js";
7
12
  import { buildFacetGroups } from "../state/facets.js";
13
+ import { FACET_PLAN_EVIDENCE } from "../state/degradations.js";
8
14
  import type { FacetGroup } from "../state/facets.js";
9
15
  import { useHostFacetLabels } from "./useFacetLabels.js";
10
16
  import type { FacetLabelResolver } from "./useFacetLabels.js";
@@ -51,6 +57,34 @@ export interface FacetPanelBag {
51
57
  readonly currency: string | undefined;
52
58
  /** Size of the largest counted set — the number `approximate` is about. */
53
59
  readonly candidates: number;
60
+ /**
61
+ * Groups the counter COUNTED and then held back, because their buckets
62
+ * describe too little of the result set. Not empty means the panel may
63
+ * NOT say "this search offers no filters" — it says how many, instead.
64
+ */
65
+ readonly withheld: readonly FacetWithheldGroup[];
66
+ /**
67
+ * The categories this answer's candidate set is made of, busiest first —
68
+ * the evidence the plan was drawn from, and the counted category filter a
69
+ * text search has no other way to offer. Empty when the plan is the
70
+ * queried category's own. `category` is the same slash-joined id path
71
+ * `SearchQueryState.category` takes.
72
+ */
73
+ readonly categories: readonly FacetCategoryCount[];
74
+ /**
75
+ * Where the plan came from: `"category"` (the queried category's own
76
+ * authored schema) or `"evidence"` (the categories the candidate set
77
+ * actually contains).
78
+ */
79
+ readonly plan: string;
80
+ /**
81
+ * True when the server could not work out a plan AT ALL — the engine has
82
+ * no `category_counts` verb (`degraded: ["facet_plan_evidence"]`). An
83
+ * empty panel then means "we do not know", so a skin must not print
84
+ * "there are no filters" over it; the degradation notice is what tells
85
+ * the reader.
86
+ */
87
+ readonly planUnavailable: boolean;
54
88
  /** Facet values + ranges + geo currently applied. */
55
89
  readonly activeFilters: number;
56
90
 
@@ -67,6 +101,9 @@ const EMPTY_META: FacetMeta = {
67
101
  skipped: [],
68
102
  dropped_filters: [],
69
103
  core_ranges: [],
104
+ plan: "category",
105
+ withheld: [],
106
+ categories: [],
70
107
  };
71
108
 
72
109
  /**
@@ -174,6 +211,17 @@ export function useFacetPanel(props: {
174
211
  ?.card?.["currency"] as string | undefined
175
212
  : undefined,
176
213
  candidates: meta.candidates,
214
+ withheld: meta.withheld ?? [],
215
+ categories: meta.categories ?? [],
216
+ plan: meta.plan ?? "category",
217
+ // Read off the raw list rather than through `parseDegradations`: the
218
+ // question is whether THIS answer supports the sentence "no filters",
219
+ // and the banner's own parsing is a different job on a different
220
+ // surface. Guarded on the READY state, never flattened — a query still
221
+ // in flight has not said anything about its facet plan.
222
+ planUnavailable:
223
+ envelope.status === "ready" &&
224
+ envelope.data.degraded.includes(FACET_PLAN_EVIDENCE),
177
225
  activeFilters,
178
226
  toggle: toggleFilter,
179
227
  setRange,
@@ -71,6 +71,21 @@ export interface SearchStateBag {
71
71
  * Draw it as an invitation ("near me"), never as state.
72
72
  */
73
73
  readonly geoOffer: SearchGeo | undefined;
74
+ /**
75
+ * Whether the location this search is USING is the one the host offered —
76
+ * i.e. the visitor pressed "near me" (or followed a link built by somebody
77
+ * who did) and has not moved the pin since.
78
+ *
79
+ * A fact about provenance, and only the provider holds it: `state.geo` is a
80
+ * centre and a radius, and every way of arriving at a centre produces the
81
+ * same three numbers. Without it a summary line has to guess, and the guess
82
+ * it shipped was "a chosen place on the map" — said to a person who had
83
+ * pressed a button and never opened a map.
84
+ *
85
+ * `false` whenever there is no offer to compare against, which includes
86
+ * every search on a host that offers none.
87
+ */
88
+ readonly geoIsOffer: boolean;
74
89
 
75
90
  setText(q: string): void;
76
91
  setSort(sort: string | null): void;
@@ -92,6 +107,29 @@ export interface SearchStateBag {
92
107
  patch(patch: SearchStatePatch): void;
93
108
  }
94
109
 
110
+ /**
111
+ * How close two coordinates have to be to be the same place, in degrees.
112
+ *
113
+ * The applied location makes a round trip through the query string, so the
114
+ * numbers that come back are the ones `String(lat)` produced rather than the
115
+ * float the device handed over. 1e-6 degrees is ~11cm — far below any
116
+ * position a browser reports and far above any rounding the codec introduces.
117
+ */
118
+ const SAME_PLACE_EPSILON = 1e-6;
119
+
120
+ /** Is the applied location the offered one? See {@link SearchStateBag.geoIsOffer}. */
121
+ function sameCenter(
122
+ applied: SearchGeo | undefined,
123
+ offered: SearchGeo | undefined
124
+ ): boolean {
125
+ if (applied === undefined || offered === undefined) return false;
126
+ if (applied.kind !== "center" || offered.kind !== "center") return false;
127
+ return (
128
+ Math.abs(applied.lat - offered.lat) < SAME_PLACE_EPSILON &&
129
+ Math.abs(applied.lon - offered.lon) < SAME_PLACE_EPSILON
130
+ );
131
+ }
132
+
95
133
  const StateContext = createContext<SearchStateBag | null>(null);
96
134
 
97
135
  export interface SearchStateProviderProps extends ParseSearchStateOptions {
@@ -178,6 +216,23 @@ export function SearchStateProvider(
178
216
 
179
217
  const bag = useMemo<SearchStateBag>(() => {
180
218
  const state = parsed.state;
219
+ /**
220
+ * The offer, carrying the radius the URL already asked for.
221
+ *
222
+ * `?radius_km=300` with no `lat`/`lon` narrows nothing — there is no
223
+ * point to measure from — but it IS a number the person typed, and the
224
+ * offer used to ignore it twice over: the button advertised the host's
225
+ * own 25km, and pressing it wrote 25 into the address over the 300 that
226
+ * was already there. Three things now agree, which is the whole of the
227
+ * fix: what the link asked for, what the button says, and what pressing
228
+ * it does. A bbox offer is left alone — a box has no radius to carry.
229
+ */
230
+ const offer: SearchGeo | undefined =
231
+ geoOffer !== undefined &&
232
+ geoOffer.kind === "center" &&
233
+ parsed.orphanRadiusKm !== undefined
234
+ ? { ...geoOffer, radiusKm: parsed.orphanRadiusKm }
235
+ : geoOffer;
181
236
  const apply = (
182
237
  next: SearchQueryState,
183
238
  options?: { readonly replace?: boolean }
@@ -193,7 +248,8 @@ export function SearchStateProvider(
193
248
  // — there is nothing left to offer, and a control that kept drawing
194
249
  // "near me" beside an applied location would be inviting a person to
195
250
  // re-answer a question they can already see the answer to.
196
- geoOffer: state.geo === undefined ? geoOffer : undefined,
251
+ geoOffer: state.geo === undefined ? offer : undefined,
252
+ geoIsOffer: sameCenter(state.geo, offer),
197
253
 
198
254
  // Typing replaces rather than pushes: one history entry per letter
199
255
  // would make Back useless, which is the control the spec's acceptance
@@ -209,10 +265,10 @@ export function SearchStateProvider(
209
265
  apply(patchSearchState(state, { geo }));
210
266
  },
211
267
  acceptGeoOffer: () => {
212
- if (geoOffer === undefined || state.geo !== undefined) return;
268
+ if (offer === undefined || state.geo !== undefined) return;
213
269
  // A PUSH, like any other filter the person applies: Back takes the
214
270
  // narrowing off again, which is the same promise every chip makes.
215
- apply(patchSearchState(state, { geo: geoOffer }));
271
+ apply(patchSearchState(state, { geo: offer }));
216
272
  },
217
273
  // A page size is a preference, not a step through the results.
218
274
  setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
@@ -32,7 +32,7 @@
32
32
  * 4. the raw value.
33
33
  *
34
34
  * `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
35
- * and it leaves an option it could not name with `label === value` — which is
35
+ * and it marks an option it could not name `labelSource: "none"` — which is
36
36
  * what makes "did anyone name this?" answerable without a second lookup. Only
37
37
  * those values are asked about, so the host is never called for a value the
38
38
  * server or the schema already captioned, and a resolver that returns nothing
@@ -98,7 +98,7 @@ const NO_REQUESTS: readonly FacetLabelRequest[] = [];
98
98
  /** The values of one group nobody has named — see the precedence note above. */
99
99
  function unresolvedValues(group: FacetGroup): readonly string[] {
100
100
  return group.options
101
- .filter((option) => option.label === option.value)
101
+ .filter((option) => option.labelSource === "none")
102
102
  .map((option) => option.value)
103
103
  .sort((a, b) => a.localeCompare(b));
104
104
  }
@@ -157,14 +157,14 @@ export function useHostFacetLabels(
157
157
  return {
158
158
  ...group,
159
159
  options: group.options.map((option) => {
160
- // Precedence again, enforced rather than assumed: an option whose
161
- // label already differs from its value was named by the server or
162
- // the schema, and the host does not get to overwrite either.
163
- if (option.label !== option.value) return option;
160
+ // Precedence again, enforced rather than assumed: an option the
161
+ // server or the schema already named is not the host's to
162
+ // overwrite. The source says so; the strings cannot.
163
+ if (option.labelSource !== "none") return option;
164
164
  const caption = named[option.value];
165
165
  return caption === undefined || caption.length === 0
166
166
  ? option
167
- : { ...option, label: caption };
167
+ : { ...option, label: caption, labelSource: "host" as const };
168
168
  }),
169
169
  };
170
170
  })
package/src/i18n/es.ts CHANGED
@@ -75,6 +75,10 @@ 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",
78
82
  "search.facets.not_counted": "sin contar",
79
83
  "search.facets.drill_down_hint":
80
84
  "Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
@@ -89,6 +93,12 @@ export const searchI18nBundleEs: I18nDictionary = {
89
93
  "search.facets.show_less": "Ver menos",
90
94
  "search.facets.search": "Buscar un filtro",
91
95
  "search.facets.search_empty": "Ningún filtro coincide con esto",
96
+ "search.facets.dictionary_search": "Buscar un valor",
97
+ "search.facets.dictionary_empty": "Ningún valor coincide con esto",
98
+ "search.facets.dictionary_chosen": "Elegido",
99
+ "search.facets.popular_all": "Todos",
100
+ "search.partition.all": "Todos",
101
+ "search.partition.label": "Sección",
92
102
  "search.facets.match_count.one": "{count} anuncio coincide",
93
103
  "search.facets.match_count.other": "{count} anuncios coinciden",
94
104
  "search.facets.range_invalid":
@@ -125,12 +135,14 @@ export const searchI18nBundleEs: I18nDictionary = {
125
135
 
126
136
  "search.geo.title": "Ubicación",
127
137
  "search.geo.radius_km": "A menos de {km} km",
138
+ "search.geo.radius_km_short": "{km} km",
128
139
  "search.geo.radius_label": "Radio, km",
129
140
  "search.geo.clear": "En cualquier lugar",
130
141
  "search.geo.near_me": "Cerca de mí",
131
142
  "search.geo.everywhere": "Buscando en todas partes",
132
143
  "search.geo.box": "Dentro del área mostrada",
133
144
  "search.geo.chosen_place": "Un lugar elegido en el mapa",
145
+ "search.geo.near_you": "Cerca de ti",
134
146
 
135
147
  "search.url.issues_title": "Parte de este enlace no se pudo leer",
136
148
  "search.url.issue.not_a_number":
@@ -141,6 +153,8 @@ export const searchI18nBundleEs: I18nDictionary = {
141
153
  "el área del mapa de este enlace está incompleta, así que se ignoró",
142
154
  "search.url.issue.range_malformed":
143
155
  "al rango «{param}» de este enlace le faltan números, así que se ignoró",
156
+ "search.url.issue.radius_without_place":
157
+ "este enlace pide un radio pero no nombra ningún lugar, así que aún no se acota nada — elige un lugar y se aplicará ese mismo radio",
144
158
 
145
159
  "search.degraded.title": "Lo que esta búsqueda no pudo hacer",
146
160
  "search.degraded.typo_tolerance":
@@ -151,6 +165,8 @@ export const searchI18nBundleEs: I18nDictionary = {
151
165
  "search.degraded.exact_facet_counts": "Los recuentos de filtros son aproximados.",
152
166
  "search.degraded.category_rollup":
153
167
  "Pueden faltar subcategorías en estos resultados: el servicio de categorías no respondió.",
168
+ "search.degraded.facet_plan_evidence":
169
+ "No pudimos determinar qué filtros encajan con estos resultados, así que puede haber más de los que muestra el panel.",
154
170
  "search.degraded.scorer":
155
171
  "El parámetro de ranking «{scorer}» no se aplicó: el motor configurado no puede evaluarlo.",
156
172
  "search.degraded.unknown":