@stapel/search-react 0.11.0 → 0.13.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 (125) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/MODULE.md +118 -3
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +60 -6
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/searchApi.d.ts +13 -6
  7. package/dist/api/searchApi.d.ts.map +1 -1
  8. package/dist/api/searchApi.js.map +1 -1
  9. package/dist/api/types.d.ts +107 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js +29 -0
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/FacetPanelPane.d.ts +13 -2
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +3 -1
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/FilterChips.d.ts +88 -3
  18. package/dist/default/FilterChips.d.ts.map +1 -1
  19. package/dist/default/FilterChips.js +161 -13
  20. package/dist/default/FilterChips.js.map +1 -1
  21. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  22. package/dist/default/LocationSummaryLine.js +1 -1
  23. package/dist/default/LocationSummaryLine.js.map +1 -1
  24. package/dist/default/SearchBox.d.ts.map +1 -1
  25. package/dist/default/SearchBox.js +74 -4
  26. package/dist/default/SearchBox.js.map +1 -1
  27. package/dist/default/SearchPage.d.ts +29 -1
  28. package/dist/default/SearchPage.d.ts.map +1 -1
  29. package/dist/default/SearchPage.js +25 -10
  30. package/dist/default/SearchPage.js.map +1 -1
  31. package/dist/default/SearchResultCard.d.ts.map +1 -1
  32. package/dist/default/SearchResultCard.js +83 -37
  33. package/dist/default/SearchResultCard.js.map +1 -1
  34. package/dist/default/SearchResultsPane.d.ts +22 -0
  35. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  36. package/dist/default/SearchResultsPane.js +2 -2
  37. package/dist/default/SearchResultsPane.js.map +1 -1
  38. package/dist/default/SortSelect.d.ts +23 -0
  39. package/dist/default/SortSelect.d.ts.map +1 -1
  40. package/dist/default/SortSelect.js +29 -9
  41. package/dist/default/SortSelect.js.map +1 -1
  42. package/dist/default/cardPhotos.d.ts +65 -0
  43. package/dist/default/cardPhotos.d.ts.map +1 -0
  44. package/dist/default/cardPhotos.js +127 -0
  45. package/dist/default/cardPhotos.js.map +1 -0
  46. package/dist/default/index.d.ts +2 -2
  47. package/dist/default/index.d.ts.map +1 -1
  48. package/dist/default/index.js +1 -1
  49. package/dist/default/index.js.map +1 -1
  50. package/dist/headless/FacetPanel.d.ts +10 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +9 -1
  53. package/dist/headless/FacetPanel.js.map +1 -1
  54. package/dist/headless/useFacetLabels.d.ts +39 -0
  55. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  56. package/dist/headless/useFacetLabels.js +129 -0
  57. package/dist/headless/useFacetLabels.js.map +1 -0
  58. package/dist/headless/useSearchBox.d.ts +71 -0
  59. package/dist/headless/useSearchBox.d.ts.map +1 -1
  60. package/dist/headless/useSearchBox.js +67 -2
  61. package/dist/headless/useSearchBox.js.map +1 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +10 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +55 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +67 -0
  68. package/dist/i18n/keys.js.map +1 -1
  69. package/dist/i18n/ru.d.ts.map +1 -1
  70. package/dist/i18n/ru.js +12 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +7 -5
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +4 -3
  75. package/dist/index.js.map +1 -1
  76. package/dist/model/queries.d.ts +6 -5
  77. package/dist/model/queries.d.ts.map +1 -1
  78. package/dist/model/queries.js +4 -3
  79. package/dist/model/queries.js.map +1 -1
  80. package/dist/model/queryKeys.d.ts +12 -0
  81. package/dist/model/queryKeys.d.ts.map +1 -1
  82. package/dist/model/queryKeys.js +7 -0
  83. package/dist/model/queryKeys.js.map +1 -1
  84. package/dist/model/runtime.d.ts +40 -3
  85. package/dist/model/runtime.d.ts.map +1 -1
  86. package/dist/model/runtime.js +2 -1
  87. package/dist/model/runtime.js.map +1 -1
  88. package/dist/nav/manifest.d.ts +21 -0
  89. package/dist/nav/manifest.d.ts.map +1 -1
  90. package/dist/nav/manifest.js +3 -2
  91. package/dist/nav/manifest.js.map +1 -1
  92. package/dist/state/facets.d.ts +56 -15
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +101 -27
  95. package/dist/state/facets.js.map +1 -1
  96. package/llms.txt +3 -3
  97. package/manifest.json +33 -4
  98. package/nav-manifest.json +4 -3
  99. package/package.json +11 -11
  100. package/src/analytics/generated/events.json +1 -1
  101. package/src/api/generated/schema.ts +60 -6
  102. package/src/api/searchApi.ts +13 -6
  103. package/src/api/types.ts +115 -1
  104. package/src/default/FacetPanelPane.tsx +16 -2
  105. package/src/default/FilterChips.tsx +285 -38
  106. package/src/default/LocationSummaryLine.tsx +6 -1
  107. package/src/default/SearchBox.tsx +134 -6
  108. package/src/default/SearchPage.tsx +79 -5
  109. package/src/default/SearchResultCard.tsx +157 -71
  110. package/src/default/SearchResultsPane.tsx +48 -11
  111. package/src/default/SortSelect.tsx +70 -9
  112. package/src/default/cardPhotos.ts +195 -0
  113. package/src/default/index.ts +10 -2
  114. package/src/headless/FacetPanel.tsx +20 -1
  115. package/src/headless/useFacetLabels.ts +172 -0
  116. package/src/headless/useSearchBox.ts +132 -5
  117. package/src/i18n/es.ts +11 -0
  118. package/src/i18n/keys.ts +69 -0
  119. package/src/i18n/ru.ts +13 -0
  120. package/src/index.ts +22 -3
  121. package/src/model/queries.ts +6 -5
  122. package/src/model/queryKeys.ts +23 -0
  123. package/src/model/runtime.ts +43 -4
  124. package/src/nav/manifest.ts +24 -2
  125. package/src/state/facets.ts +123 -39
@@ -33,6 +33,7 @@ import {
33
33
  GatedButton,
34
34
  LoadList,
35
35
  SkinTheme,
36
+ visuallyHidden,
36
37
  } from "@stapel/tokens-antd/skin";
37
38
  import { spacing } from "@stapel/tokens";
38
39
  import type { SearchItem } from "../api/types.js";
@@ -168,6 +169,28 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
168
169
  * heading list is for.
169
170
  */
170
171
  readonly headingLevel?: 1 | 2 | 3 | 4 | 5;
172
+ /**
173
+ * How the row above the results is arranged. Default `"banner"`.
174
+ *
175
+ * `"banner"` — the heading on the left, the count and the toolbar on the
176
+ * right of one line. A desktop results page, where that line is wide enough
177
+ * to hold all three.
178
+ *
179
+ * `"compact"` — the phone form, and it is a different SHAPE rather than the
180
+ * same shape at a smaller size. At 390px the banner's one line wraps to
181
+ * four: a display-size "Results", the count under it, the sort control under
182
+ * that, and the surface's action under that — a whole viewport of chrome
183
+ * above the first card, which is what a live phone SERP printed. Compact
184
+ * gives the toolbar its own row (the surface arranges it: sort at one end,
185
+ * its action at the other) and puts the COUNT immediately above the cards,
186
+ * where it is a caption for the list rather than a fourth heading.
187
+ *
188
+ * The heading does not disappear — a results screen whose only heading is
189
+ * gone has a document outline that starts at the footer. It becomes
190
+ * visually hidden, so a screen reader's heading list is unchanged and the
191
+ * viewport is not spent saying "Results" over a list of results.
192
+ */
193
+ readonly header?: "banner" | "compact";
171
194
  }
172
195
 
173
196
  function Count(props: { bag: SearchResultsBag }): ReactElement | null {
@@ -248,19 +271,33 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
248
271
  <SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
249
272
  {(bag) => (
250
273
  <Flex vertical gap={spacing[4]}>
251
- <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
252
- <Typography.Title
253
- level={props.headingLevel ?? 4}
254
- style={{ margin: 0 }}
255
- data-testid="search-results-heading"
256
- >
257
- {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
258
- </Typography.Title>
259
- <Flex align="center" wrap gap={spacing[3]}>
260
- <Count bag={bag} />
274
+ {props.header === "compact" ? (
275
+ <Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
276
+ <Typography.Title
277
+ level={props.headingLevel ?? 4}
278
+ style={visuallyHidden}
279
+ data-testid="search-results-heading"
280
+ >
281
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
282
+ </Typography.Title>
261
283
  {props.toolbar}
284
+ <Count bag={bag} />
285
+ </Flex>
286
+ ) : (
287
+ <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
288
+ <Typography.Title
289
+ level={props.headingLevel ?? 4}
290
+ style={{ margin: 0 }}
291
+ data-testid="search-results-heading"
292
+ >
293
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
294
+ </Typography.Title>
295
+ <Flex align="center" wrap gap={spacing[3]}>
296
+ <Count bag={bag} />
297
+ {props.toolbar}
298
+ </Flex>
262
299
  </Flex>
263
- </Flex>
300
+ )}
264
301
 
265
302
  <DegradationNotice
266
303
  degradations={bag.degradations}
@@ -34,10 +34,37 @@ import { sortLabelKey } from "./sortLabels.js";
34
34
  */
35
35
  export const SORT_SELECT_MIN_WIDTH = 200;
36
36
 
37
+ /** Why `sort=distance` is refused without a centre — the server's own code, so
38
+ * the control and the 400 it would have earned say the same sentence. */
39
+ const SORT_DISTANCE_BLOCKED = "error.400.search_sort_needs_center";
40
+
37
41
  export interface SortSelectProps {
38
42
  /** The sort the SERVER applied, shown when the URL names none. Omitted, it
39
43
  * is read from the page already in cache — see {@link useAppliedSort}. */
40
44
  readonly appliedSort?: string | undefined;
45
+ /**
46
+ * The one-line form, for a phone toolbar. Default `false`.
47
+ *
48
+ * Three things change, and the third is the interesting one:
49
+ *
50
+ * - the "Sort" caption goes (the select already shows a sort by name; the
51
+ * accessible name keeps the word);
52
+ * - the {@link SORT_SELECT_MIN_WIDTH} floor goes, so the control shares one
53
+ * row with whatever the surface puts beside it instead of pushing it to
54
+ * the next line;
55
+ * - the blocked option's REASON moves from a line under the control into
56
+ * the option's own label.
57
+ *
58
+ * That last move is not the reason being dropped. This file exists because
59
+ * the reason used to live in a `title=` a phone can never surface, and a
60
+ * phone is exactly where "sort by distance" is greyed out most often. On a
61
+ * 390px toolbar the reason as a separate row costs a whole band of the
62
+ * viewport above the first result — so it goes where the person actually
63
+ * meets the refusal: on the disabled row of the open list, which a screen
64
+ * reader reads out with the option and a thumb reads at the moment of the
65
+ * tap. Nothing is hidden; it is closer to the thing it explains.
66
+ */
67
+ readonly compact?: boolean;
41
68
  }
42
69
 
43
70
  export function SortSelect(props: SortSelectProps): ReactElement {
@@ -65,7 +92,48 @@ export function SortSelect(props: SortSelectProps): ReactElement {
65
92
  // four sorts work perfectly well without a location.
66
93
  const distance: ActionAvailability = hasCentre
67
94
  ? actionAvailable()
68
- : actionBlocked("error.400.search_sort_needs_center");
95
+ : actionBlocked(SORT_DISTANCE_BLOCKED);
96
+
97
+ const optionsFor = (describedBy?: string): {
98
+ readonly value: string;
99
+ readonly label: string;
100
+ readonly disabled: boolean;
101
+ }[] =>
102
+ values.map((value) => {
103
+ const key = sortLabelKey(value);
104
+ const label = key !== undefined ? t(key) : value;
105
+ const blocked = value === "distance" && !hasCentre;
106
+ return {
107
+ value,
108
+ // In the compact form the option carries its own reason — see
109
+ // `SortSelectProps.compact`. Elsewhere `GatedControl` renders it once,
110
+ // beside the control, and repeating it here would say it twice.
111
+ label:
112
+ blocked && props.compact === true && describedBy === undefined
113
+ ? `${label} — ${t(SORT_DISTANCE_BLOCKED)}`
114
+ : label,
115
+ disabled: blocked,
116
+ };
117
+ });
118
+
119
+ if (props.compact === true) {
120
+ return (
121
+ <Select<string>
122
+ data-testid="search-sort"
123
+ data-stapel-gated={hasCentre ? "available" : "blocked"}
124
+ aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
125
+ // `minWidth: 0` and not the floor: a control that refuses to be
126
+ // narrower than 200px is a control that wraps a two-item toolbar onto
127
+ // two rows at 390px.
128
+ style={{ minWidth: 0, flex: "0 1 auto" }}
129
+ value={active ?? null}
130
+ onChange={(next) => {
131
+ setSort(next);
132
+ }}
133
+ options={optionsFor()}
134
+ />
135
+ );
136
+ }
69
137
 
70
138
  return (
71
139
  <GatedControl gate={distance} testId="search-sort-gate">
@@ -83,14 +151,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
83
151
  onChange={(next) => {
84
152
  setSort(next);
85
153
  }}
86
- options={values.map((value) => {
87
- const key = sortLabelKey(value);
88
- return {
89
- value,
90
- label: key !== undefined ? t(key) : value,
91
- disabled: value === "distance" && !hasCentre,
92
- };
93
- })}
154
+ options={optionsFor(bind["aria-describedby"] ?? "")}
94
155
  />
95
156
  </Flex>
96
157
  )}
@@ -0,0 +1,195 @@
1
+ /**
2
+ * WHAT A SEARCH CARD ACTUALLY STORES FOR A PHOTO, and how it becomes something
3
+ * `@stapel/image` can draw.
4
+ *
5
+ * ── The defect this file exists to end ────────────────────────────────────
6
+ *
7
+ * The default card used to read `card.image` expecting an object with a `url`
8
+ * key, and fall back to `card.image_url`. Neither is what this fleet emits:
9
+ *
10
+ * - `image_url` is a convention NOTHING in the fleet writes. It was declared
11
+ * in `GENERIC_CARD_FIELDS`, drawn in the demos from a data URI, and never
12
+ * once served by a backend.
13
+ * - `card.image` IS emitted — by `stapel-classified`'s search projection —
14
+ * and it is a plain `<type>/<hash>` STRING, not an object.
15
+ * - where a card DOES carry an object (chat's subject card, which serves the
16
+ * same CDN render descriptor its attachments carry), that object has `ref`
17
+ * and `variants[]` and NO top-level `url` — so the `"url" in rich` guard
18
+ * rejected the one rich shape the fleet has.
19
+ *
20
+ * The net effect was a card with no photo on every consumer that did not pass
21
+ * its own `renderCard`. Read the two real shapes instead, and read `images[]`
22
+ * first: since stapel-classified 0.7.0 the projection carries the whole
23
+ * gallery in seller order, deduplicated and capped by `CARD_IMAGES_LIMIT`,
24
+ * with the singular `image` kept as `images[0]`.
25
+ *
26
+ * ── Three shapes, one output ──────────────────────────────────────────────
27
+ *
28
+ * 1. A CDN reference (`"image/9f2c…"`) — resolved through the runtime's
29
+ * {@link SearchImageResolver}, the same seam `@stapel/listings-react`
30
+ * states, for the same reason: no contract in this fleet resolves a
31
+ * stranger's reference, so the deployment hands its own knowledge in once.
32
+ * 2. A URL a doc type stored directly (`"https://…"`, `"/media/…"`,
33
+ * `"data:image/…"`) — no ladder to shop, so it degrades to `source:
34
+ * "link"`. A CDN reference is `<type>/<hash>`: no scheme and no leading
35
+ * slash, so the two are told apart by shape and never by a guess.
36
+ * 3. A render descriptor object — `variants[]` with a `tier` each, an inline
37
+ * `preview_b64`, geometry. Read defensively (the field is `unknown` by
38
+ * contract) exactly as `@stapel/chat-react` reads the same descriptor.
39
+ */
40
+ import type { StapelImage } from "@stapel/image";
41
+ import type { SearchImageResolver } from "../model/runtime.js";
42
+
43
+ /** A card's photo fields, and how many of them the card carried at all. */
44
+ export interface CardPhotos {
45
+ /**
46
+ * How many photo entries the card STORES — before resolving. `0` means the
47
+ * doc type has no photo for this row (or no photo field at all), which is a
48
+ * different thing from "a photo nothing could resolve", and the card draws
49
+ * the two differently.
50
+ */
51
+ readonly stored: number;
52
+ /** The ones that became something drawable, in the stored order. */
53
+ readonly images: readonly StapelImage[];
54
+ }
55
+
56
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ function str(value: unknown): string | undefined {
61
+ return typeof value === "string" && value.length > 0 ? value : undefined;
62
+ }
63
+
64
+ function num(value: unknown): number | null {
65
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
66
+ }
67
+
68
+ /**
69
+ * A stored string that is already a URL rather than a CDN reference.
70
+ *
71
+ * stapel-cdn's unit is `<type>/<hash>` — a relative pair with no scheme and no
72
+ * leading slash — so anything carrying a scheme, a protocol-relative prefix or
73
+ * a leading `/` is a URL the doc type stored itself.
74
+ */
75
+ function isUrl(value: string): boolean {
76
+ return (
77
+ value.startsWith("/") ||
78
+ value.startsWith("data:") ||
79
+ /^[a-z][a-z0-9+.-]*:/i.test(value)
80
+ );
81
+ }
82
+
83
+ /** A bare URL as the descriptor `<Image>` consumes: one rung, no ladder. */
84
+ function linkImage(url: string): StapelImage {
85
+ return {
86
+ source: "link",
87
+ url,
88
+ mime: null,
89
+ width: null,
90
+ height: null,
91
+ aspect: null,
92
+ square: false,
93
+ preview_b64: null,
94
+ variants: [],
95
+ };
96
+ }
97
+
98
+ /**
99
+ * One rung of a render descriptor's ladder, in `@stapel/image`'s spelling.
100
+ *
101
+ * `tier` arrives in two spellings inside the SAME array — an int for the
102
+ * ladder rungs and the string sentinel `"original"` for the entry the snapshot
103
+ * builder appends. `String()` is right for both, which is why there is no
104
+ * branch here (`@stapel/cdn-react`'s `model/refs.ts` argues it at length).
105
+ */
106
+ function rung(value: unknown): StapelImage["variants"][number] | undefined {
107
+ if (!isRecord(value)) return undefined;
108
+ const url = str(value["url"]);
109
+ if (url === undefined) return undefined;
110
+ const tier = value["tier"];
111
+ const branch = value["branch"];
112
+ return {
113
+ tier: typeof tier === "number" || typeof tier === "string" ? String(tier) : "",
114
+ branch: branch === "w" || branch === "h" ? branch : null,
115
+ url,
116
+ width: num(value["width"]),
117
+ height: num(value["height"]),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * A CDN render descriptor → the descriptor `<Image>` consumes.
123
+ *
124
+ * The display URL is the descriptor's own `url` when it has one, else the
125
+ * `original` rung, else the largest rung — a snapshot carries no
126
+ * `original_url`, so the top of the ladder IS the original. With no rung at
127
+ * all the inline `preview_b64` is used: a real, honestly blurry image beats
128
+ * nothing. A descriptor with neither is not drawable and says so.
129
+ */
130
+ function metaImage(meta: Readonly<Record<string, unknown>>): StapelImage | undefined {
131
+ const raw = meta["variants"];
132
+ const variants = (Array.isArray(raw) ? raw : [])
133
+ .map(rung)
134
+ .filter((v): v is StapelImage["variants"][number] => v !== undefined);
135
+ const original = variants.find((v) => v.tier === "original");
136
+ const largest = variants.reduce<StapelImage["variants"][number] | undefined>(
137
+ (best, v) => {
138
+ const size = Number(v.tier);
139
+ if (!Number.isFinite(size)) return best;
140
+ return best === undefined || size > Number(best.tier) ? v : best;
141
+ },
142
+ undefined
143
+ );
144
+ const preview = str(meta["preview_b64"]);
145
+ const url = str(meta["url"]) ?? original?.url ?? largest?.url ?? preview;
146
+ if (url === undefined) return undefined;
147
+ return {
148
+ source: "cdn",
149
+ url,
150
+ mime: str(meta["mime"]) ?? null,
151
+ width: num(meta["width"]),
152
+ height: num(meta["height"]),
153
+ aspect: num(meta["aspect"]),
154
+ square: meta["square"] === true,
155
+ preview_b64: preview ?? null,
156
+ variants,
157
+ };
158
+ }
159
+
160
+ /** One stored entry, in whichever of the three shapes it arrived. */
161
+ export function cardImage(
162
+ value: unknown,
163
+ resolve: SearchImageResolver | undefined
164
+ ): StapelImage | undefined {
165
+ const ref = str(value);
166
+ if (ref !== undefined) {
167
+ return isUrl(ref) ? linkImage(ref) : resolve?.(ref);
168
+ }
169
+ if (isRecord(value)) return metaImage(value);
170
+ return undefined;
171
+ }
172
+
173
+ /**
174
+ * The card's gallery: `images[]` when the doc type carries one, the singular
175
+ * `image` otherwise.
176
+ *
177
+ * Never both — `stapel-classified` stores `image` as `images[0]`, so reading
178
+ * the singular after the list would draw the first photo twice. The singular
179
+ * stays the fallback for a doc type that never grew a list.
180
+ */
181
+ export function readCardPhotos(
182
+ card: Readonly<Record<string, unknown>>,
183
+ resolve: SearchImageResolver | undefined
184
+ ): CardPhotos {
185
+ const list = card["images"];
186
+ const stored: readonly unknown[] = Array.isArray(list)
187
+ ? list
188
+ : card["image"] === undefined || card["image"] === null
189
+ ? []
190
+ : [card["image"]];
191
+ const images = stored
192
+ .map((entry) => cardImage(entry, resolve))
193
+ .filter((image): image is StapelImage => image !== undefined);
194
+ return { stored: stored.length, images };
195
+ }
@@ -48,8 +48,16 @@ export type {
48
48
  SearchResultsRenderer,
49
49
  } from "./SearchResultsPane.js";
50
50
 
51
- export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
52
- export type { FilterChipsProps } from "./FilterChips.js";
51
+ export {
52
+ FilterChips,
53
+ CHIP_BAND_ORDER,
54
+ CHIP_ROW_CLASS,
55
+ CHIP_ROW_STYLE_HREF,
56
+ categoryLeaf,
57
+ chipRowCss,
58
+ orderChipFilters,
59
+ } from "./FilterChips.js";
60
+ export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
53
61
 
54
62
  export { LocationSummaryLine } from "./LocationSummaryLine.js";
55
63
  export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
@@ -6,6 +6,8 @@ import type { FacetMeta, SearchRange } from "../api/types.js";
6
6
  import { useSearchQuery } from "../model/queries.js";
7
7
  import { buildFacetGroups } from "../state/facets.js";
8
8
  import type { FacetGroup } from "../state/facets.js";
9
+ import { useHostFacetLabels } from "./useFacetLabels.js";
10
+ import type { FacetLabelResolver } from "./useFacetLabels.js";
9
11
  import { useSearchState } from "./SearchStateProvider.js";
10
12
 
11
13
  /** The bag `<FacetPanel>` hands its render prop. */
@@ -87,6 +89,8 @@ export function FacetPanel(props: {
87
89
  /** BCP-47 tag for `date`-typed option labels. Defaults to the runtime's. */
88
90
  locale?: string;
89
91
  enabled?: boolean;
92
+ /** The host's vocabulary lookup — see {@link FacetLabelResolver}. */
93
+ resolveFacetLabels?: FacetLabelResolver;
90
94
  children: (bag: FacetPanelBag) => ReactNode;
91
95
  }): ReactNode {
92
96
  return props.children(
@@ -96,6 +100,9 @@ export function FacetPanel(props: {
96
100
  : {}),
97
101
  ...(props.locale !== undefined ? { locale: props.locale } : {}),
98
102
  ...(props.enabled !== undefined ? { enabled: props.enabled } : {}),
103
+ ...(props.resolveFacetLabels !== undefined
104
+ ? { resolveFacetLabels: props.resolveFacetLabels }
105
+ : {}),
99
106
  })
100
107
  );
101
108
  }
@@ -116,6 +123,13 @@ export function useFacetPanel(props: {
116
123
  categoryFeatures?: readonly FeatureDef[];
117
124
  locale?: string;
118
125
  enabled?: boolean;
126
+ /**
127
+ * The host's lookup for values neither the answer nor the schema names —
128
+ * see {@link FacetLabelResolver}. Threaded here rather than into each skin
129
+ * so the chip row and the filter panel, which both call this hook, cannot
130
+ * end up printing two different words for one value.
131
+ */
132
+ resolveFacetLabels?: FacetLabelResolver;
119
133
  } = {}): FacetPanelBag {
120
134
  const { state: searchState, setFilter, setRange, clearAll, toggleFilter, activeFilters } =
121
135
  useSearchState();
@@ -142,8 +156,13 @@ export function useFacetPanel(props: {
142
156
  })
143
157
  );
144
158
 
159
+ // The host seam runs AFTER `buildFacetGroups`, on what it could not name:
160
+ // the precedence is server captions, then the schema's own option table,
161
+ // then this. See `useFacetLabels.ts`.
162
+ const labelled = useHostFacetLabels(groups, props.resolveFacetLabels, props.locale);
163
+
145
164
  return {
146
- state: groups,
165
+ state: labelled,
147
166
  approximate: meta.approximate,
148
167
  skipped: meta.skipped,
149
168
  counted: meta.counted,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The HOST LABEL SEAM — the third source of a facet option's caption, and the
3
+ * only one that can reach a vocabulary.
4
+ *
5
+ * ── The defect ────────────────────────────────────────────────────────────
6
+ *
7
+ * A live classified deployment's category schema types `vendor`, `model`,
8
+ * `memory_size` and `color_ref_select` as `ref_select`: their config carries
9
+ * no `options` table at all, only a POINTER —
10
+ * `{"optionsRef": {"level": "Vendor", "vocabulary": "avito-phone-catalog"}}`.
11
+ * The words live in the vocabulary the pointer names, which is a different
12
+ * service with a different client. So the two label sources this pair already
13
+ * has both come up empty: the answer's `facet_labels` because the deployed
14
+ * server predates stapel-search 0.4.0 and sends no such key, and the schema
15
+ * because a pointer is not a table. The chips printed `apple`, `128-gb`,
16
+ * `chernyy` — storage slugs, at buyers.
17
+ *
18
+ * This pair must not grow a vocabulary client to fix that: it would tie every
19
+ * storefront's search to a service it may not run, and the host ALREADY has
20
+ * that client (`@stapel/attributes-react`'s `VocabularyClientProvider` is
21
+ * mounted for the composer). So the fetch is the host's and the discipline is
22
+ * this module's — the same division as `renderGeoFilter` and
23
+ * `renderCategoryFilter`, one seam lower because what crosses it is data
24
+ * rather than a control.
25
+ *
26
+ * ── Precedence, stated once ───────────────────────────────────────────────
27
+ *
28
+ * 1. the answer's `facet_labels` — the server saw the write-time snapshot;
29
+ * 2. the feature def's inline `options` table — for the plain `select`
30
+ * family, which carries its words with it;
31
+ * 3. this resolver;
32
+ * 4. the raw value.
33
+ *
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
36
+ * what makes "did anyone name this?" answerable without a second lookup. Only
37
+ * those values are asked about, so the host is never called for a value the
38
+ * server or the schema already captioned, and a resolver that returns nothing
39
+ * for a value leaves the raw value on screen. A chip that silently dropped an
40
+ * option would be worse than one showing a slug: the option is real, it has a
41
+ * count, and it is the only way to reach those documents.
42
+ *
43
+ * ── The fetch discipline ──────────────────────────────────────────────────
44
+ *
45
+ * TanStack Query, exactly like the pair's other three reads, and for the four
46
+ * reasons that made it right there:
47
+ *
48
+ * - **batched per group.** One call per facet slug carrying every unresolved
49
+ * value of it, not one call per chip.
50
+ * - **cached.** `staleTime: Infinity` — a vocabulary term's caption changes
51
+ * when somebody edits the catalogue, not between two clicks on a filter.
52
+ * - **deduplicated.** The chip row, the filter panel and the page's layout
53
+ * probe each call `useFacetPanel`, so three components ask for the same
54
+ * group's captions in one render pass; one query key means one request.
55
+ * - **aborted on supersession.** The `signal` is the query's own, so a
56
+ * resolver that honours it stops a request whose answer nobody will read.
57
+ */
58
+ import { useQueries } from "@tanstack/react-query";
59
+ import { mapLoad } from "@stapel/core";
60
+ import type { LoadState } from "@stapel/core";
61
+ import type { FeatureDef } from "@stapel/attributes-react";
62
+ import { searchQueryKeys } from "../model/queryKeys.js";
63
+ import type { FacetGroup } from "../state/facets.js";
64
+
65
+ /** What the host is asked to name. */
66
+ export interface FacetLabelRequest {
67
+ /** The facet slug — `vendor`, `color_ref_select`. */
68
+ readonly slug: string;
69
+ /**
70
+ * The category's own def for the slug, when the host supplied a schema.
71
+ * It carries the `optionsRef` pointer (vocabulary + level) a resolver needs
72
+ * to know WHICH vocabulary these codes belong to, so a host does not have to
73
+ * keep a second copy of the schema to answer.
74
+ */
75
+ readonly feature: FeatureDef | undefined;
76
+ /**
77
+ * The values nobody has named yet, sorted. Only the unresolved ones: a
78
+ * resolver is never asked about a value the server or the schema captioned.
79
+ */
80
+ readonly values: readonly string[];
81
+ }
82
+
83
+ /**
84
+ * A host's answer: `{value: caption}`.
85
+ *
86
+ * Partial answers are the expected case, not an error — a code the vocabulary
87
+ * no longer holds simply is not in the map, and its chip keeps printing the
88
+ * raw value. Returning `{}` is a legitimate "I cannot name any of these".
89
+ */
90
+ export type FacetLabelResolver = (
91
+ request: FacetLabelRequest,
92
+ options: { readonly signal: AbortSignal }
93
+ ) => Promise<Readonly<Record<string, string>>>;
94
+
95
+ /** One frozen empty list, so a render with no unresolved values is stable. */
96
+ const NO_REQUESTS: readonly FacetLabelRequest[] = [];
97
+
98
+ /** The values of one group nobody has named — see the precedence note above. */
99
+ function unresolvedValues(group: FacetGroup): readonly string[] {
100
+ return group.options
101
+ .filter((option) => option.label === option.value)
102
+ .map((option) => option.value)
103
+ .sort((a, b) => a.localeCompare(b));
104
+ }
105
+
106
+ function isCaptionMap(value: unknown): value is Readonly<Record<string, string>> {
107
+ return typeof value === "object" && value !== null && !Array.isArray(value);
108
+ }
109
+
110
+ /**
111
+ * Fill in what the server and the schema could not name, through the host.
112
+ *
113
+ * Returns the groups UNCHANGED — same object identity — when there is no
114
+ * resolver or nothing came back, so a page with the seam unwired pays neither
115
+ * a request nor a re-render.
116
+ */
117
+ export function useHostFacetLabels(
118
+ groups: LoadState<readonly FacetGroup[]>,
119
+ resolve: FacetLabelResolver | undefined,
120
+ locale: string | undefined
121
+ ): LoadState<readonly FacetGroup[]> {
122
+ const requests: readonly FacetLabelRequest[] =
123
+ resolve === undefined || groups.status !== "ready"
124
+ ? NO_REQUESTS
125
+ : groups.data
126
+ .map((group) => ({
127
+ slug: group.slug,
128
+ feature: group.feature,
129
+ values: unresolvedValues(group),
130
+ }))
131
+ .filter((request) => request.values.length > 0);
132
+
133
+ const answers = useQueries({
134
+ queries: requests.map((request) => ({
135
+ queryKey: searchQueryKeys.facetLabels(request.slug, request.values, locale),
136
+ queryFn: async ({ signal }: { signal: AbortSignal }): Promise<unknown> =>
137
+ resolve === undefined ? {} : await resolve(request, { signal }),
138
+ staleTime: Number.POSITIVE_INFINITY,
139
+ // A vocabulary that answered 500 answers 500 again a millisecond later,
140
+ // and three retries only delay the chip's raw value by three round
141
+ // trips — the same discipline the pair's other reads keep.
142
+ retry: false,
143
+ })),
144
+ });
145
+
146
+ const captions = new Map<string, Readonly<Record<string, string>>>();
147
+ requests.forEach((request, index) => {
148
+ const data = answers[index]?.data;
149
+ if (isCaptionMap(data)) captions.set(request.slug, data);
150
+ });
151
+ if (captions.size === 0) return groups;
152
+
153
+ return mapLoad(groups, (list) =>
154
+ list.map((group) => {
155
+ const named = captions.get(group.slug);
156
+ if (named === undefined) return group;
157
+ return {
158
+ ...group,
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;
164
+ const caption = named[option.value];
165
+ return caption === undefined || caption.length === 0
166
+ ? option
167
+ : { ...option, label: caption };
168
+ }),
169
+ };
170
+ })
171
+ );
172
+ }