@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
package/src/api/types.ts CHANGED
@@ -13,17 +13,85 @@ import type { components } from "./generated/schema.js";
13
13
  /** The generated schema table — the one source of truth for wire shapes. */
14
14
  export type Schemas = components["schemas"];
15
15
 
16
- /** `GET /query` 200 — the whole envelope. */
17
- export type SearchResponse = Schemas["SearchResponse"];
16
+ /**
17
+ * One slug's captions: the GROUP's own name, and the words for its values.
18
+ *
19
+ * `label` is the group heading the server resolved from the category's
20
+ * feature definition, in the request's own language. It is the only source
21
+ * that always exists — `categoryFeatures` is an optional slot a live
22
+ * classified board never filled, and without it every heading in the panel
23
+ * was the raw index slug. `null` is the server saying it has no name for the
24
+ * slug either; ABSENT is a server too old to send one, and both read the same
25
+ * way here (fall through to the schema).
26
+ *
27
+ * WHAT THE GENERATOR LOST: the field is newer than this pair's pinned
28
+ * `schema.json`, so it is declared here as optional-and-nullable rather than
29
+ * regenerated into a shape a deployed older server does not send.
30
+ */
31
+ export type FacetLabels = Schemas["FacetLabels"] & {
32
+ readonly label?: string | null;
33
+ };
34
+
35
+ /** `facet_labels` as a whole: `{slug: {label, translatable, values}}`. */
36
+ export type FacetLabelsMap = Readonly<Record<string, FacetLabels>>;
37
+
38
+ /** `GET /query` 200 — the whole envelope. Corrected in two places: see
39
+ * {@link FacetMeta} and {@link FacetLabels}. */
40
+ export type SearchResponse = Omit<
41
+ Schemas["SearchResponse"],
42
+ "facet_meta" | "facet_labels"
43
+ > & {
44
+ readonly facet_meta: FacetMeta;
45
+ readonly facet_labels: FacetLabelsMap;
46
+ };
18
47
 
19
48
  /** One result row. `promoted` is present on EVERY item under EVERY sort — a
20
49
  * mandatory marking (DSA Art. 26), not an optional field, which is why the
21
50
  * card slot's contract carries it and why the default skin renders it. */
22
51
  export type SearchItem = Schemas["SearchItem"];
23
52
 
24
- /** The honesty block beside the counts: `approximate`, `candidates`,
25
- * `counted`, `skipped`. Rendered, never swallowed (spec §4.2). */
26
- export type FacetMeta = Schemas["FacetMeta"];
53
+ /**
54
+ * One group the counter COUNTED and then held back, because its buckets
55
+ * describe too little of the result set (`FACET_MIN_COVERAGE`).
56
+ *
57
+ * The existence of this list is what makes "this search offers no filters"
58
+ * a false sentence whenever it is not empty (D175).
59
+ */
60
+ export interface FacetWithheldGroup {
61
+ readonly slug: string;
62
+ /** Sum of that group's bucket counts — how much of the set it describes. */
63
+ readonly coverage: number;
64
+ /** Size of the candidate set `coverage` is a fraction of. */
65
+ readonly candidates: number;
66
+ }
67
+
68
+ /** One category the candidate set is made of. */
69
+ export interface FacetCategoryCount {
70
+ /** The slash-joined id path (`"32/149/163"`) — the SAME string the
71
+ * `category` filter and `SearchQueryState.category` already take, so a
72
+ * panel can offer it as a filter without translating anything. */
73
+ readonly category: string;
74
+ readonly count: number;
75
+ }
76
+
77
+ /**
78
+ * The honesty block beside the counts: `approximate`, `candidates`,
79
+ * `counted`, `skipped`, and (stapel-search 0.12.0+) where the facet plan came
80
+ * from. Rendered, never swallowed (spec §4.2).
81
+ *
82
+ * WHAT THE GENERATOR LOST: drf-spectacular describes `withheld` and
83
+ * `categories` as bare `object` arrays, so the generated members are
84
+ * `{[key: string]: unknown}[]` — the two fields a panel has to read
85
+ * field-by-field are the two it cannot. Both are corrected here to the
86
+ * documented row shapes; nothing else about `FacetMeta` is hand-written.
87
+ */
88
+ export type FacetMeta = Omit<
89
+ Schemas["FacetMeta"],
90
+ "withheld" | "categories"
91
+ > & {
92
+ readonly withheld: readonly FacetWithheldGroup[];
93
+ readonly categories: readonly FacetCategoryCount[];
94
+ };
27
95
 
28
96
  /** `GET /suggest` 200, as the CURRENT generated schema describes it. */
29
97
  export type SuggestResponse = Schemas["SuggestResponse"];
@@ -259,6 +327,10 @@ export type SearchDegradationKind =
259
327
  | "exact_total"
260
328
  | "exact_facet_counts"
261
329
  | "category_rollup"
330
+ /** The engine has no `category_counts` verb, so the categories the result
331
+ * set is made of are unknown and no evidence facet plan could be drawn.
332
+ * An empty filter panel then means "we do not know", not "there are none". */
333
+ | "facet_plan_evidence"
262
334
  | "scorer"
263
335
  | "unknown";
264
336
 
@@ -19,6 +19,11 @@
19
19
  * 3. **A 60-option facet is not a list, it is a wall.** Every catalogue has
20
20
  * one — brand, model, city — and printing all of it pushes every group
21
21
  * under it off the screen.
22
+ * 4. **A vocabulary is not a long list, it is a DICTIONARY.** 418 car makes
23
+ * behind "Show all (418)" is a control whose only mode is "read all of
24
+ * it": the busiest values, a box, and the rest reachable by typing is the
25
+ * only shape that answers "I want a Toyota" in one gesture. See
26
+ * {@link isDictionaryFacet}.
22
27
  *
23
28
  * ── The presentation is DERIVED, never configured here ────────────────────
24
29
  *
@@ -59,12 +64,17 @@
59
64
  */
60
65
  import { useState } from "react";
61
66
  import type { CSSProperties, ReactElement } from "react";
62
- import { Button, Checkbox, Flex, Typography } from "antd";
67
+ import { Button, Checkbox, Flex, Input, Typography } from "antd";
63
68
  import { useT } from "@stapel/core";
64
69
  import { radii, spacing } from "@stapel/tokens";
65
- import { featureConfig, featureType } from "@stapel/attributes-react";
70
+ import {
71
+ VOCABULARY_BACKED_TYPES,
72
+ featureConfig,
73
+ featureType,
74
+ } from "@stapel/attributes-react";
66
75
  import type { FeatureDef } from "@stapel/attributes-react";
67
76
  import type { FacetGroup, FacetOption } from "../state/facets.js";
77
+ import { translitPrefixMatch } from "../state/translit.js";
68
78
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
69
79
 
70
80
  /**
@@ -77,8 +87,19 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
77
87
  */
78
88
  export const FACET_VISIBLE_OPTIONS = 8;
79
89
 
80
- /** The three shapes a facet group takes. */
81
- export type FacetGroupShape = "segmented" | "nested" | "checkbox";
90
+ /**
91
+ * From how many values a group stops being a list and becomes a DICTIONARY —
92
+ * the same eight, because it is the same fold: past it the group is drawn as
93
+ * its busiest values plus a box that searches the rest.
94
+ */
95
+ export const FACET_DICTIONARY_THRESHOLD: number = FACET_VISIBLE_OPTIONS;
96
+
97
+ /** The four shapes a facet group takes. */
98
+ export type FacetGroupShape =
99
+ | "segmented"
100
+ | "nested"
101
+ | "checkbox"
102
+ | "dictionary";
82
103
 
83
104
  /** The option rows of a group, already nested where the schema nests them. */
84
105
  export interface FacetOptionNode {
@@ -126,17 +147,52 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
126
147
  }
127
148
 
128
149
  /**
129
- * Which of the three shapes a group takes.
150
+ * Is this group a DICTIONARY a vocabulary's level, too long to scroll?
151
+ *
152
+ * Two ways to be one, because the schema is an optional slot and the live
153
+ * case is the one where it is empty:
154
+ *
155
+ * - the def types the slug `ref_select`/`ref_hierarchical_select`, i.e. its
156
+ * config is a POINTER into a vocabulary and there was never an option
157
+ * table to draw;
158
+ * - there is NO def at all and the answer came back with more values than a
159
+ * fold. At a live classified's cars branch the storefront passed an empty
160
+ * feature list, so the 418 makes arrived as an unnamed, untyped group of
161
+ * 418 checkboxes behind "Show all (418)". A box is the only control that
162
+ * answers that, and refusing to draw one because the schema is missing
163
+ * punishes the buyer for the wiring.
164
+ *
165
+ * Either way it takes more than {@link FACET_DICTIONARY_THRESHOLD} EVIDENCE
166
+ * buckets — values the answer actually counted. A zero-filled option table or
167
+ * a schema-only tail is a list a person can already read, and a box over it
168
+ * would search for values no document carries.
169
+ */
170
+ export function isDictionaryFacet(group: FacetGroup): boolean {
171
+ const buckets = group.options.filter(
172
+ (option) => option.count !== null && option.count > 0
173
+ ).length;
174
+ if (buckets <= FACET_DICTIONARY_THRESHOLD) return false;
175
+ const feature = group.feature;
176
+ if (feature === undefined) return true;
177
+ const type = featureType(feature);
178
+ return type !== undefined && VOCABULARY_BACKED_TYPES.includes(type);
179
+ }
180
+
181
+ /**
182
+ * Which of the four shapes a group takes.
130
183
  *
131
184
  * Order matters: a hierarchical facet is nested even when it is single-choice,
132
- * because losing the tree costs more than losing the pills.
185
+ * because losing the tree costs more than losing the pills; and a dictionary
186
+ * is a dictionary before it is a checkbox list, because the checkbox list is
187
+ * the shape it was drawn as when nobody could pick a make.
133
188
  */
134
189
  export function facetGroupShape(group: FacetGroup): FacetGroupShape {
135
190
  const feature = group.feature;
136
191
  if (feature !== undefined && featureType(feature) === "hierarchical_select") {
137
192
  return "nested";
138
193
  }
139
- return singleChoice(feature) ? "segmented" : "checkbox";
194
+ if (singleChoice(feature)) return "segmented";
195
+ return isDictionaryFacet(group) ? "dictionary" : "checkbox";
140
196
  }
141
197
 
142
198
  /**
@@ -327,6 +383,142 @@ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
327
383
  );
328
384
  }
329
385
 
386
+ /**
387
+ * The list a dictionary scrolls in. A vocabulary level is 418 makes: without
388
+ * a ceiling the group alone is longer than the rail, and the box that filters
389
+ * it scrolls off the top of the panel while you type into it.
390
+ */
391
+ const DICTIONARY_LIST: CSSProperties = {
392
+ maxBlockSize: 320,
393
+ overflowY: "auto",
394
+ // The scroll must not clip a focus ring against the panel's edge.
395
+ paddingInlineEnd: spacing[1],
396
+ };
397
+
398
+ /**
399
+ * A dictionary group: what is CHOSEN, a box, and the busiest values.
400
+ *
401
+ * ── Why the chosen values are their own block ─────────────────────────────
402
+ *
403
+ * They are the only rows that are not free to leave. A person who filtered by
404
+ * `Toyota` and then typed `bmw` must still be able to see — and undo — the
405
+ * filter that is narrowing what they are reading; a checkbox that scrolls out
406
+ * of the fold takes its own off-switch with it. So the chosen rows sit above
407
+ * the box, out of the filtered list entirely, and the list below never
408
+ * repeats them.
409
+ *
410
+ * ── The box filters LOCALLY, and matches across alphabets ─────────────────
411
+ *
412
+ * The buckets are already on the client — they arrived with the answer — so
413
+ * the box is a filter over an array and not a request per keystroke.
414
+ * `translitPrefixMatch` is what makes it usable in a catalogue whose values
415
+ * are Latin and whose buyers type Cyrillic: a Cyrillic "timberlend" finds
416
+ * `Timberland`.
417
+ * It is presentation, exactly like the panel's own search: nothing here
418
+ * touches the URL, because what a person typed to FIND a filter is not part
419
+ * of the search they would share.
420
+ */
421
+ function DictionaryBody(props: {
422
+ readonly group: FacetGroup;
423
+ readonly onToggle: (slug: string, value: string) => void;
424
+ /** How many values before the box has to be used. */
425
+ readonly visible: number;
426
+ }): ReactElement {
427
+ const t = useT();
428
+ const { group } = props;
429
+ const [needle, setNeedle] = useState("");
430
+ const [expanded, setExpanded] = useState(false);
431
+
432
+ const chosen = group.options.filter((option) => option.selected);
433
+ const rest = [...group.options.filter((option) => !option.selected)].sort(
434
+ (a, b) => (b.count ?? 0) - (a.count ?? 0)
435
+ );
436
+ const query = needle.trim();
437
+ const matched =
438
+ query === ""
439
+ ? rest
440
+ : rest.filter(
441
+ (option) =>
442
+ translitPrefixMatch(query, option.label) ||
443
+ translitPrefixMatch(query, option.value)
444
+ );
445
+ // The fold only exists while nothing is typed: a query has already narrowed
446
+ // the list, and hiding its tail behind "Show all" would hide the answer.
447
+ const folded = query === "" && !expanded && matched.length > props.visible;
448
+ const shown = folded ? matched.slice(0, props.visible) : matched;
449
+
450
+ return (
451
+ <Flex vertical gap={spacing[1]} data-testid={`facet-dictionary-${group.slug}`}>
452
+ {chosen.length > 0 && (
453
+ <Flex
454
+ vertical
455
+ gap={spacing[1]}
456
+ data-testid={`facet-dictionary-chosen-${group.slug}`}
457
+ >
458
+ <Typography.Text type="secondary">
459
+ {t(SEARCH_I18N_KEYS.facetsDictionaryChosen)}
460
+ </Typography.Text>
461
+ {chosen.map((option) => (
462
+ <CheckboxRow
463
+ key={option.value}
464
+ group={group}
465
+ node={{ option, depth: 0 }}
466
+ onToggle={props.onToggle}
467
+ />
468
+ ))}
469
+ </Flex>
470
+ )}
471
+ <Input
472
+ allowClear
473
+ size="small"
474
+ value={needle}
475
+ placeholder={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
476
+ aria-label={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
477
+ data-testid={`facet-dictionary-search-${group.slug}`}
478
+ onChange={(event) => {
479
+ setNeedle(event.target.value);
480
+ }}
481
+ />
482
+ {shown.length === 0 ? (
483
+ <Typography.Text
484
+ type="secondary"
485
+ data-testid={`facet-dictionary-empty-${group.slug}`}
486
+ >
487
+ {t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
488
+ </Typography.Text>
489
+ ) : (
490
+ <Flex vertical gap={spacing[1]} style={DICTIONARY_LIST}>
491
+ {shown.map((option) => (
492
+ <CheckboxRow
493
+ key={option.value}
494
+ group={group}
495
+ node={{ option, depth: 0 }}
496
+ onToggle={props.onToggle}
497
+ />
498
+ ))}
499
+ </Flex>
500
+ )}
501
+ {query === "" && matched.length > props.visible && (
502
+ <Button
503
+ type="link"
504
+ size="small"
505
+ style={{ alignSelf: "flex-start", paddingInline: 0 }}
506
+ data-testid={`facet-more-${group.slug}`}
507
+ data-analytics="none"
508
+ data-analytics-reason="expanding a filter group is a read, not a flow step"
509
+ onClick={() => {
510
+ setExpanded((was) => !was);
511
+ }}
512
+ >
513
+ {expanded
514
+ ? t(SEARCH_I18N_KEYS.facetsShowLess)
515
+ : t(SEARCH_I18N_KEYS.facetsShowAll, { count: matched.length })}
516
+ </Button>
517
+ )}
518
+ </Flex>
519
+ );
520
+ }
521
+
330
522
  export interface FacetGroupControlProps {
331
523
  readonly group: FacetGroup;
332
524
  readonly onToggle: (slug: string, value: string) => void;
@@ -386,6 +578,11 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
386
578
  data-testid={`facet-group-${group.slug}`}
387
579
  data-counted={group.counted ? "true" : "false"}
388
580
  data-shape={shape}
581
+ // Who named this heading — `none` means the raw slug is on screen
582
+ // because the answer sent no label and the schema defines none. It is
583
+ // drawn (a heading a person cannot read still beats none) and it is
584
+ // MARKED, so a storefront's own test can refuse to ship it.
585
+ data-label-source={group.labelSource}
389
586
  >
390
587
  {props.heading !== false &&
391
588
  (disclosure ? (
@@ -421,7 +618,15 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
421
618
  {/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
422
619
  checkboxes are still a hundred stops for a screen reader, and the
423
620
  measured rail held 118 of them. */}
424
- {open && (
621
+ {open && shape === "dictionary" && (
622
+ <DictionaryBody
623
+ group={group}
624
+ onToggle={props.onToggle}
625
+ visible={limit ?? FACET_VISIBLE_OPTIONS}
626
+ />
627
+ )}
628
+
629
+ {open && shape !== "dictionary" && (
425
630
  <>
426
631
  {shape === "segmented" ? (
427
632
  <Flex wrap gap={spacing[2]}>
@@ -91,6 +91,7 @@ import { featureName } from "@stapel/attributes-react";
91
91
  import type { FeatureDef } from "@stapel/attributes-react";
92
92
  import type { SearchGeo } from "../api/types.js";
93
93
  import { FacetPanel } from "../headless/FacetPanel.js";
94
+ import type { FacetPanelBag } from "../headless/FacetPanel.js";
94
95
  import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
95
96
  import { useSearchState } from "../headless/SearchStateProvider.js";
96
97
  import { useAppliedCount } from "../headless/useAppliedCount.js";
@@ -421,6 +422,40 @@ function skippedNames(
421
422
  .join(", ");
422
423
  }
423
424
 
425
+ /**
426
+ * The empty arm of the panel — the ONE place "this search offers no filters"
427
+ * may be said, and the two answers that forbid it (D175).
428
+ *
429
+ * A group list of zero is not the same claim. `withheld` names groups the
430
+ * counter counted and then held back for describing too little of the result
431
+ * set: they exist, so the honest line is how many. `planUnavailable` means
432
+ * the server could not work a plan out at all — the reader hears that from
433
+ * `<DegradationNotice>`, and this arm's only job is to not contradict it.
434
+ *
435
+ * A COMPONENT rather than a ternary inline in `empty=`, because `LoadList`
436
+ * reads a nullish `empty` as "no arm given" and draws its own default, which
437
+ * is the sentence again. An element that renders `null` says nothing; a
438
+ * `null` prop says it louder.
439
+ */
440
+ function FacetsEmptyArm(props: { readonly bag: FacetPanelBag }): ReactElement | null {
441
+ const t = useT();
442
+ const tPlural = useTPlural();
443
+ const withheld = props.bag.withheld.length;
444
+ if (withheld > 0) {
445
+ return (
446
+ <EmptyState
447
+ compact
448
+ title={tPlural(SEARCH_I18N_KEYS.facetsWithheld, { count: withheld })}
449
+ testId="facets-withheld"
450
+ />
451
+ );
452
+ }
453
+ if (props.bag.planUnavailable) return null;
454
+ return (
455
+ <EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />
456
+ );
457
+ }
458
+
424
459
  export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
425
460
  const t = useT();
426
461
  const { state } = useSearchState();
@@ -566,7 +601,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
566
601
  state={bag.state}
567
602
  testId="facets"
568
603
  skeletonRows={4}
569
- empty={<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />}
604
+ empty={<FacetsEmptyArm bag={bag} />}
570
605
  failed={(error) => (
571
606
  <ErrorAlert
572
607
  testId="facets-failed"
@@ -94,6 +94,9 @@ import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
94
94
 
95
95
  /** The class the row carries, for {@link locationLineCss}. */
96
96
  export const LOCATION_LINE_CLASS = "stapel-search-location-line";
97
+ /** The class the ONE location control carries — the place and the offer,
98
+ * inside a single box. See {@link locationLineCss} for what it fixes. */
99
+ export const LOCATION_LINE_GROUP_CLASS = "stapel-search-location-line-group";
97
100
  /** The class the shrinking half carries. */
98
101
  export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
99
102
  /** The class the truncating label carries. */
@@ -117,6 +120,7 @@ export function locationLineCss(): string {
117
120
  const where = `.${LOCATION_LINE_WHERE_CLASS}`;
118
121
  const label = `.${LOCATION_LINE_LABEL_CLASS}`;
119
122
  const end = `.${LOCATION_LINE_END_CLASS}`;
123
+ const group = `.${LOCATION_LINE_GROUP_CLASS}`;
120
124
  return [
121
125
  // `min-width:0` on the row too: a flex item's default `min-width:auto`
122
126
  // is what makes a nested flex row refuse to shrink at all.
@@ -139,6 +143,35 @@ export function locationLineCss(): string {
139
143
  `text-overflow:ellipsis;white-space:nowrap}`,
140
144
  // The word a person is looking for never loses a pixel to a place name.
141
145
  `${end}{flex:0 0 auto}`,
146
+ // ── ONE control, not two red links at opposite ends of the page ────────
147
+ //
148
+ // Measured on a live 1440px leaf: the row is 992px wide, "📍 Searching
149
+ // everywhere" sat at x=370 and "Near me · Within 25 km" at x=776, both
150
+ // drawn in the same brand red, with ~400px of air between them and
151
+ // nothing — no border, no ground, no heading — saying they were about the
152
+ // same thing. Two identical-looking links at opposite ends of a strip are
153
+ // read as two unrelated controls, which is what they looked like.
154
+ //
155
+ // So the place and the offer share ONE box with a border and a ground,
156
+ // sitting together at the leading edge, with a hairline between them: the
157
+ // eye gets a single object that says where this search is looking and
158
+ // offers the one move that would change it. The border comes from the
159
+ // role token, so it is right in both themes by construction.
160
+ // `min-inline-size:0` explicitly, and `flex-shrink` on the box rather
161
+ // than on its contents: a flex item's `min-width:auto` refuses to go
162
+ // below its own content, so without this the group pushed the filters
163
+ // door off the end of a 390px row instead of letting the place name give
164
+ // way — which is the half that can.
165
+ `${group}{display:flex;align-items:stretch;flex:0 1 auto;min-inline-size:0;` +
166
+ `border:1px solid ${cssVar("border")};border-radius:${String(radii.md)}px;` +
167
+ `background:${cssVar("surface-raised")};overflow:hidden}`,
168
+ // The two halves are separated by the box's own line, never by a gap: a
169
+ // gap inside a bordered group reads as two groups again.
170
+ `${group}>*+*{border-inline-start:1px solid ${cssVar("border")}}`,
171
+ `${group}>*{border-radius:0}`,
172
+ // Both halves pay for their own breathing room; the antd Button's inline
173
+ // padding is zeroed above so the box, not the button, sets the measure.
174
+ `${group}>*{padding-inline:${String(spacing[2])}px}`,
142
175
  ].join("");
143
176
  }
144
177
 
@@ -149,7 +182,9 @@ const ROW: CSSProperties = { width: "100%" };
149
182
  /** The shrinking half. The flex rules it needs live in the hoisted sheet
150
183
  * (they have to reach antd's own wrapper span); this is the chrome. */
151
184
  const LOCATION: CSSProperties = {
152
- paddingInline: 0,
185
+ // No `paddingInline` here: the group's sheet sets it for both halves, and
186
+ // an inline style would beat the sheet and leave one half flush against the
187
+ // border while the other breathed.
153
188
  textAlign: "start",
154
189
  };
155
190
 
@@ -159,7 +194,7 @@ const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
159
194
 
160
195
  /** The offer. It never shrinks: it is three words and a number, and half of
161
196
  * "Near me" is not an offer. */
162
- const OFFER: CSSProperties = { flex: "0 0 auto", paddingInline: 0 };
197
+ const OFFER: CSSProperties = { flex: "0 0 auto" };
163
198
 
164
199
  /**
165
200
  * The count, IN the flow.
@@ -239,7 +274,8 @@ export function LocationSummaryLine(
239
274
  props: LocationSummaryLineProps
240
275
  ): ReactElement {
241
276
  const t = useT();
242
- const { state, activeFilters, geoOffer, acceptGeoOffer } = useSearchState();
277
+ const { state, activeFilters, geoOffer, geoIsOffer, acceptGeoOffer } =
278
+ useSearchState();
243
279
  const [open, setOpen] = useState(false);
244
280
  const geo = state.geo;
245
281
 
@@ -257,13 +293,25 @@ export function LocationSummaryLine(
257
293
  // the question), so this row never shows an offer beside a place.
258
294
  const offerRadius =
259
295
  geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
260
- ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geoOffer.radiusKm })
296
+ ? t(SEARCH_I18N_KEYS.geoRadiusKmShort, { km: geoOffer.radiusKm })
261
297
  : undefined;
262
298
 
299
+ // What the line CALLS the place it is looking at.
300
+ //
301
+ // The host's name wins — it is the only one that can be checked by a reader
302
+ // — then the fact the provider holds, and only then the shape of the
303
+ // constraint. The middle arm is D184's second half: pressing "Near me"
304
+ // turned this line into "A chosen place on the map" for a person who had
305
+ // never opened a map. `geoIsOffer` is the provider reporting how the search
306
+ // came to be here, rather than this file inferring it from three numbers
307
+ // that look the same whatever produced them.
263
308
  const where: ReactNode =
264
309
  geo === undefined
265
310
  ? t(SEARCH_I18N_KEYS.geoEverywhere)
266
- : (props.geoLabel ?? geoSummaryFallback(geo, t));
311
+ : (props.geoLabel ??
312
+ (geoIsOffer
313
+ ? t(SEARCH_I18N_KEYS.geoNearYou)
314
+ : geoSummaryFallback(geo, t)));
267
315
 
268
316
  return (
269
317
  <>
@@ -279,60 +327,73 @@ export function LocationSummaryLine(
279
327
  data-testid="search-location-summary"
280
328
  data-geo={geo === undefined ? "off" : "on"}
281
329
  >
282
- {/* The glyph is rendered as a CHILD rather than through antd's `icon`
283
- prop: the icon slot sits outside the wrapper span, so the label
284
- beside it could not be given a min-width of its own — and a label
285
- that cannot shrink is a label that overflows. */}
286
- <Button
287
- type="link"
288
- style={LOCATION}
289
- className={LOCATION_LINE_WHERE_CLASS}
290
- data-testid="search-location-open"
291
- data-analytics="none"
292
- data-analytics-reason="opening the location sheet is a read, not a flow step"
293
- onClick={() => {
294
- setOpen(true);
295
- }}
330
+ {/* ONE control: where this search is looking, and while the
331
+ question is still open the one move that would change it. See
332
+ `locationLineCss` for the measurement that made them one box. */}
333
+ <div
334
+ className={LOCATION_LINE_GROUP_CLASS}
335
+ data-testid="search-location-group"
296
336
  >
297
- <span style={PIN}>
298
- <PinGlyph />
299
- </span>
300
- <span
301
- className={LOCATION_LINE_LABEL_CLASS}
302
- data-testid="search-location-label"
303
- >
304
- {where}
305
- {radius !== undefined && (
306
- <span data-testid="search-location-radius">
307
- {" · "}
308
- {radius}
309
- </span>
310
- )}
311
- </span>
312
- </Button>
337
+ {/* The glyph is rendered as a CHILD rather than through antd's
338
+ `icon` prop: the icon slot sits outside the wrapper span, so the
339
+ label beside it could not be given a min-width of its own — and
340
+ a label that cannot shrink is a label that overflows.
313
341
 
314
- {/* The offer, and nothing is applied until it is pressed. Drawn only
315
- when the host has a position to offer AND the search carries no
316
- location of its own the provider enforces the second half, so
317
- this is one condition, not two that could disagree. */}
318
- {geoOffer !== undefined && (
342
+ `type="text"`, not `type="link"`: this half is a STATEMENT about
343
+ where the search is looking, and painting it the brand colour
344
+ made it indistinguishable from the offer beside it. The offer
345
+ keeps the colour, because the offer is the action. */}
319
346
  <Button
320
- type="link"
321
- style={OFFER}
322
- data-testid="search-location-offer"
347
+ type="text"
348
+ style={LOCATION}
349
+ className={LOCATION_LINE_WHERE_CLASS}
350
+ data-testid="search-location-open"
323
351
  data-analytics="none"
324
- data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
325
- onClick={acceptGeoOffer}
352
+ data-analytics-reason="opening the location sheet is a read, not a flow step"
353
+ onClick={() => {
354
+ setOpen(true);
355
+ }}
326
356
  >
327
- {t(SEARCH_I18N_KEYS.geoNearMe)}
328
- {offerRadius !== undefined && (
329
- <span data-testid="search-location-offer-radius">
330
- {" · "}
331
- {offerRadius}
332
- </span>
333
- )}
357
+ <span style={PIN}>
358
+ <PinGlyph />
359
+ </span>
360
+ <span
361
+ className={LOCATION_LINE_LABEL_CLASS}
362
+ data-testid="search-location-label"
363
+ >
364
+ {where}
365
+ {radius !== undefined && (
366
+ <span data-testid="search-location-radius">
367
+ {" · "}
368
+ {radius}
369
+ </span>
370
+ )}
371
+ </span>
334
372
  </Button>
335
- )}
373
+
374
+ {/* The offer, and nothing is applied until it is pressed. Drawn only
375
+ when the host has a position to offer AND the search carries no
376
+ location of its own — the provider enforces the second half, so
377
+ this is one condition, not two that could disagree. */}
378
+ {geoOffer !== undefined && (
379
+ <Button
380
+ type="link"
381
+ style={OFFER}
382
+ data-testid="search-location-offer"
383
+ data-analytics="none"
384
+ data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
385
+ onClick={acceptGeoOffer}
386
+ >
387
+ {t(SEARCH_I18N_KEYS.geoNearMe)}
388
+ {offerRadius !== undefined && (
389
+ <span data-testid="search-location-offer-radius">
390
+ {" · "}
391
+ {offerRadius}
392
+ </span>
393
+ )}
394
+ </Button>
395
+ )}
396
+ </div>
336
397
 
337
398
  {/* "Filters", not "All filters": this end of the row shares 390px
338
399
  with a place name that can run to fifteen characters, and the word