@stapel/search-react 0.4.0 → 0.8.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 (154) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1 -1
  3. package/dist/default/DegradationNotice.d.ts +7 -0
  4. package/dist/default/DegradationNotice.d.ts.map +1 -1
  5. package/dist/default/DegradationNotice.js +17 -6
  6. package/dist/default/DegradationNotice.js.map +1 -1
  7. package/dist/default/FacetGroupControl.d.ts +40 -0
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -0
  9. package/dist/default/FacetGroupControl.js +176 -0
  10. package/dist/default/FacetGroupControl.js.map +1 -0
  11. package/dist/default/FacetPanelPane.d.ts +54 -3
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +116 -22
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/FilterChips.d.ts +28 -0
  16. package/dist/default/FilterChips.d.ts.map +1 -0
  17. package/dist/default/FilterChips.js +204 -0
  18. package/dist/default/FilterChips.js.map +1 -0
  19. package/dist/default/LanguageSelect.d.ts +9 -0
  20. package/dist/default/LanguageSelect.d.ts.map +1 -0
  21. package/dist/default/LanguageSelect.js +72 -0
  22. package/dist/default/LanguageSelect.js.map +1 -0
  23. package/dist/default/PageSizeSelect.d.ts +29 -0
  24. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  25. package/dist/default/PageSizeSelect.js +25 -0
  26. package/dist/default/PageSizeSelect.js.map +1 -0
  27. package/dist/default/RangeFilterRow.d.ts +12 -0
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  29. package/dist/default/RangeFilterRow.js +75 -0
  30. package/dist/default/RangeFilterRow.js.map +1 -0
  31. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  32. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  33. package/dist/default/RankingDisclosurePane.js +15 -15
  34. package/dist/default/RankingDisclosurePane.js.map +1 -1
  35. package/dist/default/SearchBox.d.ts +13 -0
  36. package/dist/default/SearchBox.d.ts.map +1 -0
  37. package/dist/default/SearchBox.js +67 -0
  38. package/dist/default/SearchBox.js.map +1 -0
  39. package/dist/default/SearchPage.d.ts +94 -16
  40. package/dist/default/SearchPage.d.ts.map +1 -1
  41. package/dist/default/SearchPage.js +191 -8
  42. package/dist/default/SearchPage.js.map +1 -1
  43. package/dist/default/SearchResultCard.d.ts +19 -18
  44. package/dist/default/SearchResultCard.d.ts.map +1 -1
  45. package/dist/default/SearchResultCard.js +178 -5
  46. package/dist/default/SearchResultCard.js.map +1 -1
  47. package/dist/default/SearchResultsPane.d.ts +61 -3
  48. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  49. package/dist/default/SearchResultsPane.js +51 -21
  50. package/dist/default/SearchResultsPane.js.map +1 -1
  51. package/dist/default/SortSelect.d.ts +10 -2
  52. package/dist/default/SortSelect.d.ts.map +1 -1
  53. package/dist/default/SortSelect.js +27 -24
  54. package/dist/default/SortSelect.js.map +1 -1
  55. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  56. package/dist/default/UrlIssueNotice.js +19 -1
  57. package/dist/default/UrlIssueNotice.js.map +1 -1
  58. package/dist/default/ViewSwitch.d.ts +66 -0
  59. package/dist/default/ViewSwitch.d.ts.map +1 -0
  60. package/dist/default/ViewSwitch.js +49 -0
  61. package/dist/default/ViewSwitch.js.map +1 -0
  62. package/dist/default/index.d.ts +38 -15
  63. package/dist/default/index.d.ts.map +1 -1
  64. package/dist/default/index.js +29 -12
  65. package/dist/default/index.js.map +1 -1
  66. package/dist/default/sortLabels.d.ts +5 -0
  67. package/dist/default/sortLabels.d.ts.map +1 -0
  68. package/dist/default/sortLabels.js +32 -0
  69. package/dist/default/sortLabels.js.map +1 -0
  70. package/dist/headless/FacetPanel.d.ts +17 -0
  71. package/dist/headless/FacetPanel.d.ts.map +1 -1
  72. package/dist/headless/FacetPanel.js +23 -2
  73. package/dist/headless/FacetPanel.js.map +1 -1
  74. package/dist/headless/useAppliedCount.d.ts +8 -0
  75. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  76. package/dist/headless/useAppliedCount.js +30 -0
  77. package/dist/headless/useAppliedCount.js.map +1 -0
  78. package/dist/headless/useScorerNames.d.ts +4 -0
  79. package/dist/headless/useScorerNames.d.ts.map +1 -0
  80. package/dist/headless/useScorerNames.js +34 -0
  81. package/dist/headless/useScorerNames.js.map +1 -0
  82. package/dist/headless/useSearchBox.d.ts +44 -0
  83. package/dist/headless/useSearchBox.d.ts.map +1 -0
  84. package/dist/headless/useSearchBox.js +116 -0
  85. package/dist/headless/useSearchBox.js.map +1 -0
  86. package/dist/i18n/es.d.ts.map +1 -1
  87. package/dist/i18n/es.js +38 -4
  88. package/dist/i18n/es.js.map +1 -1
  89. package/dist/i18n/keys.d.ts +55 -0
  90. package/dist/i18n/keys.d.ts.map +1 -1
  91. package/dist/i18n/keys.js +101 -4
  92. package/dist/i18n/keys.js.map +1 -1
  93. package/dist/i18n/ru.d.ts.map +1 -1
  94. package/dist/i18n/ru.js +42 -4
  95. package/dist/i18n/ru.js.map +1 -1
  96. package/dist/index.d.ts +7 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +5 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/model/queries.d.ts +26 -2
  101. package/dist/model/queries.d.ts.map +1 -1
  102. package/dist/model/queries.js +38 -1
  103. package/dist/model/queries.js.map +1 -1
  104. package/dist/state/limits.d.ts +44 -0
  105. package/dist/state/limits.d.ts.map +1 -0
  106. package/dist/state/limits.js +44 -0
  107. package/dist/state/limits.js.map +1 -0
  108. package/dist/state/ranges.d.ts +52 -0
  109. package/dist/state/ranges.d.ts.map +1 -0
  110. package/dist/state/ranges.js +108 -0
  111. package/dist/state/ranges.js.map +1 -0
  112. package/llms.txt +16 -4
  113. package/manifest.json +268 -22
  114. package/nav-manifest.json +1 -1
  115. package/package.json +15 -10
  116. package/src/analytics/generated/events.json +1 -1
  117. package/src/default/DegradationNotice.tsx +34 -12
  118. package/src/default/FacetGroupControl.tsx +312 -0
  119. package/src/default/FacetPanelPane.tsx +320 -87
  120. package/src/default/FilterChips.tsx +415 -0
  121. package/src/default/LanguageSelect.tsx +93 -0
  122. package/src/default/PageSizeSelect.tsx +80 -0
  123. package/src/default/RangeFilterRow.tsx +148 -0
  124. package/src/default/RankingDisclosurePane.tsx +67 -35
  125. package/src/default/SearchBox.tsx +130 -0
  126. package/src/default/SearchPage.tsx +454 -44
  127. package/src/default/SearchResultCard.tsx +231 -24
  128. package/src/default/SearchResultsPane.tsx +158 -71
  129. package/src/default/SortSelect.tsx +50 -37
  130. package/src/default/UrlIssueNotice.tsx +21 -3
  131. package/src/default/ViewSwitch.tsx +147 -0
  132. package/src/default/index.ts +55 -15
  133. package/src/default/sortLabels.ts +34 -0
  134. package/src/headless/FacetPanel.tsx +30 -2
  135. package/src/headless/useAppliedCount.ts +41 -0
  136. package/src/headless/useScorerNames.ts +36 -0
  137. package/src/headless/useSearchBox.ts +189 -0
  138. package/src/i18n/es.ts +46 -4
  139. package/src/i18n/keys.ts +114 -4
  140. package/src/i18n/ru.ts +50 -4
  141. package/src/index.ts +22 -2
  142. package/src/model/queries.ts +51 -1
  143. package/src/state/limits.ts +50 -0
  144. package/src/state/ranges.ts +144 -0
  145. package/dist/default/ErrorAlert.d.ts +0 -9
  146. package/dist/default/ErrorAlert.d.ts.map +0 -1
  147. package/dist/default/ErrorAlert.js +0 -27
  148. package/dist/default/ErrorAlert.js.map +0 -1
  149. package/dist/default/theme.d.ts +0 -24
  150. package/dist/default/theme.d.ts.map +0 -1
  151. package/dist/default/theme.js +0 -39
  152. package/dist/default/theme.js.map +0 -1
  153. package/src/default/ErrorAlert.tsx +0 -46
  154. package/src/default/theme.tsx +0 -66
@@ -1,5 +1,11 @@
1
1
  /**
2
- * `<FacetPanelPane>` — the antd facet panel.
2
+ * `<FacetPanelPane>` — the antd FILTER panel: everything that narrows a
3
+ * search, in the order a person narrows it.
4
+ *
5
+ * Category → language → location → numeric ranges → facet checkboxes. The last
6
+ * of those was, for three releases, the only one that existed: six of the nine
7
+ * state setters had no control at all, so a price range or a location could
8
+ * only be applied by editing the address bar (audit S-2, S-7).
3
9
  *
4
10
  * Three things it is obliged to render, all of which a naive panel drops:
5
11
  *
@@ -12,67 +18,265 @@
12
18
  * - `skipped` — the slugs the server did not count at all. Their options show
13
19
  * "not counted", never `0`. A silent zero there is the same defect class as
14
20
  * `data ?? []`: a number that looks like an answer and is not one.
21
+ *
22
+ * ── Two slots, and why they are slots ─────────────────────────────────────
23
+ *
24
+ * `renderCategoryFilter` and `renderGeoFilter` are filled by OTHER pairs:
25
+ * choosing a category means walking the catalogue tree (`categories-react`),
26
+ * and turning an address into a coordinate needs a geocoder and a map
27
+ * (`geo-react`). Neither belongs in a search package, and importing either
28
+ * would tie a storefront's search to a catalogue it might not have.
29
+ *
30
+ * What this panel does NOT do is pretend the slot is optional. An unfilled
31
+ * slot renders `SlotPlaceholder` in development — a named, visible hole rather
32
+ * than a silent absence — and, in every build, any constraint the URL already
33
+ * carries gets a control that REMOVES it. A shared link that narrows to a
34
+ * category or a point must never leave a person with no way to widen it again.
15
35
  */
16
- import type { ReactElement } from "react";
17
- import { Alert, Button, Checkbox, Empty, Flex, Spin, Tag, Typography } from "antd";
18
- import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
36
+ import type { ReactElement, ReactNode } from "react";
37
+ import {
38
+ Alert,
39
+ Button,
40
+ Divider,
41
+ Flex,
42
+ InputNumber,
43
+ Typography,
44
+ } from "antd";
45
+ import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
46
+ import {
47
+ EmptyState,
48
+ ErrorAlert,
49
+ LoadList,
50
+ SkinTheme,
51
+ } from "@stapel/tokens-antd/skin";
52
+ import { spacing } from "@stapel/tokens";
53
+ import { featureName } from "@stapel/attributes-react";
19
54
  import type { FeatureDef } from "@stapel/attributes-react";
55
+ import type { SearchGeo } from "../api/types.js";
20
56
  import { FacetPanel } from "../headless/FacetPanel.js";
21
- import type { FacetGroup, FacetOption } from "../state/facets.js";
57
+ import { useSearchState } from "../headless/SearchStateProvider.js";
58
+ import { FacetGroupControl } from "./FacetGroupControl.js";
59
+ import { buildRangeGroups } from "../state/ranges.js";
22
60
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
23
- import { ErrorAlert } from "./ErrorAlert.js";
24
- import { SearchSkinTheme } from "./theme.js";
61
+ import { LanguageSelect } from "./LanguageSelect.js";
62
+ import { RangeFilterRow } from "./RangeFilterRow.js";
25
63
  import type { ThemeModeProp } from "./types.js";
26
64
 
65
+ /** What a host's category control is handed. */
66
+ export interface CategoryFilterSlotProps {
67
+ /** The `root/leaf` path the search is narrowed to, if any. */
68
+ readonly value: string | undefined;
69
+ /** `null` widens the search back to the whole catalogue. */
70
+ readonly onChange: (path: string | null) => void;
71
+ }
72
+
73
+ /** What a host's location control is handed. */
74
+ export interface GeoFilterSlotProps {
75
+ readonly value: SearchGeo | undefined;
76
+ /** `null` clears the location constraint. */
77
+ readonly onChange: (geo: SearchGeo | null) => void;
78
+ }
79
+
27
80
  export interface FacetPanelPaneProps extends ThemeModeProp {
28
- /** The category's feature schema — the source of option LABELS. */
81
+ /** The category's feature schema — the source of option LABELS and of which
82
+ * slugs get a numeric range row. */
29
83
  readonly categoryFeatures?: readonly FeatureDef[];
30
84
  readonly locale?: string;
31
85
  readonly enabled?: boolean;
86
+ /** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
87
+ * to a path). Unfilled, an active category still gets a "clear" control. */
88
+ readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
89
+ /** The location control (`geo-react`). Unfilled, a location that arrived in
90
+ * the URL still gets its radius and a "clear" control. */
91
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
92
+ /** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
93
+ readonly languages?: readonly string[];
94
+ /**
95
+ * What the panel calls itself. `null` draws no title at all — for a surface
96
+ * that has ALREADY named it, which the phone sheet has: its dialog title and
97
+ * the panel's own heading both said "Filters", one under the other, in every
98
+ * shot of the open sheet. The row itself stays either way, because the
99
+ * "Clear all" control lives in it.
100
+ */
101
+ readonly heading?: ReactNode;
32
102
  }
33
103
 
34
- function OptionRow(props: {
35
- group: FacetGroup;
36
- option: FacetOption;
37
- onToggle: (slug: string, value: string) => void;
38
- }): ReactElement {
104
+ /** The category constraint: the host's control, or the door out of it. */
105
+ function CategoryFilter(props: {
106
+ render?: (slot: CategoryFilterSlotProps) => ReactNode;
107
+ }): ReactElement | null {
39
108
  const t = useT();
40
- const { option, group } = props;
109
+ const { state, setCategory } = useSearchState();
110
+ const value = state.category;
111
+
112
+ if (props.render !== undefined) {
113
+ return (
114
+ <Flex vertical gap={spacing[1]} data-testid="search-category">
115
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
116
+ {props.render({
117
+ value,
118
+ onChange: (path) => {
119
+ setCategory(path);
120
+ },
121
+ })}
122
+ </Flex>
123
+ );
124
+ }
125
+
126
+ if (value === undefined) {
127
+ return <SlotPlaceholder name="renderCategoryFilter" data-testid="search-category-slot" />;
128
+ }
129
+
41
130
  return (
42
- <Flex justify="space-between" align="center" gap={8}>
43
- <Checkbox
44
- checked={option.selected}
45
- data-testid={`facet-option-${group.slug}-${option.value}`}
131
+ <Flex vertical gap={spacing[1]} data-testid="search-category">
132
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
133
+ <Typography.Text type="secondary">
134
+ {t(SEARCH_I18N_KEYS.categoryCurrent, { path: value })}
135
+ </Typography.Text>
136
+ <Button
137
+ style={{ alignSelf: "flex-start" }}
138
+ data-testid="search-category-clear"
46
139
  data-analytics="none"
47
140
  data-analytics-reason="a filter is a read, not a flow step"
48
- onChange={() => {
49
- props.onToggle(group.slug, option.value);
141
+ onClick={() => {
142
+ setCategory(null);
50
143
  }}
51
144
  >
52
- {option.label}
53
- </Checkbox>
54
- {option.count === null ? (
55
- <Tag data-testid={`facet-count-${group.slug}-${option.value}`}>
56
- {t(SEARCH_I18N_KEYS.facetsNotCounted)}
57
- </Tag>
58
- ) : (
59
- <Typography.Text
60
- type="secondary"
61
- data-testid={`facet-count-${group.slug}-${option.value}`}
62
- >
63
- {option.count}
64
- </Typography.Text>
145
+ {t(SEARCH_I18N_KEYS.categoryClear)}
146
+ </Button>
147
+ </Flex>
148
+ );
149
+ }
150
+
151
+ /**
152
+ * The location constraint.
153
+ *
154
+ * SETTING a centre needs a geocoder, which is the deployment's and
155
+ * `geo-react`'s. ADJUSTING one that already exists does not: the radius is a
156
+ * number in the URL, and a link shared with `lat/lon/radius_km` is a link this
157
+ * panel can widen, tighten and clear without knowing what a map is. That is the
158
+ * difference between a slot and a hole.
159
+ */
160
+ function GeoFilter(props: {
161
+ render?: (slot: GeoFilterSlotProps) => ReactNode;
162
+ }): ReactElement | null {
163
+ const t = useT();
164
+ const { state, setGeo } = useSearchState();
165
+ const geo = state.geo;
166
+
167
+ const slot =
168
+ props.render !== undefined
169
+ ? props.render({
170
+ value: geo,
171
+ onChange: (next) => {
172
+ setGeo(next);
173
+ },
174
+ })
175
+ : geo === undefined
176
+ ? <SlotPlaceholder name="renderGeoFilter" data-testid="search-geo-slot" />
177
+ : null;
178
+
179
+ if (geo === undefined) {
180
+ // An unfilled slot is a NAMED hole in development and nothing at all in a
181
+ // production build — so the heading has to follow the placeholder rather
182
+ // than outlive it. It did not, and the live desktop panel printed
183
+ // "Location" over empty space with no location control under it
184
+ // (class NC-ORPHANFIELD): a label is a promise that a control follows.
185
+ if (props.render === undefined && !isDevBuild()) return null;
186
+ return slot === null ? null : (
187
+ <Flex vertical gap={spacing[1]} data-testid="search-geo">
188
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
189
+ {slot}
190
+ </Flex>
191
+ );
192
+ }
193
+
194
+ return (
195
+ <Flex vertical gap={spacing[1]} data-testid="search-geo">
196
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
197
+ {slot}
198
+ <Typography.Text type="secondary" data-testid="search-geo-summary">
199
+ {geo.kind === "bbox"
200
+ ? t(SEARCH_I18N_KEYS.geoBox)
201
+ : t(SEARCH_I18N_KEYS.geoCenter, {
202
+ lat: geo.lat.toFixed(3),
203
+ lon: geo.lon.toFixed(3),
204
+ })}
205
+ </Typography.Text>
206
+ {geo.kind === "center" && (
207
+ <Flex gap={spacing[2]} align="center" wrap>
208
+ <Typography.Text type="secondary" aria-hidden="true">
209
+ {t(SEARCH_I18N_KEYS.geoRadiusLabel)}
210
+ </Typography.Text>
211
+ <InputNumber
212
+ min={1}
213
+ value={geo.radiusKm ?? null}
214
+ aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
215
+ data-testid="search-geo-radius"
216
+ onChange={(value) => {
217
+ setGeo({
218
+ kind: "center",
219
+ lat: geo.lat,
220
+ lon: geo.lon,
221
+ ...(typeof value === "number" ? { radiusKm: value } : {}),
222
+ });
223
+ }}
224
+ />
225
+ </Flex>
65
226
  )}
227
+ <Button
228
+ style={{ alignSelf: "flex-start" }}
229
+ data-testid="search-geo-clear"
230
+ data-analytics="none"
231
+ data-analytics-reason="a filter is a read, not a flow step"
232
+ onClick={() => {
233
+ setGeo(null);
234
+ }}
235
+ >
236
+ {t(SEARCH_I18N_KEYS.geoClear)}
237
+ </Button>
66
238
  </Flex>
67
239
  );
68
240
  }
69
241
 
242
+ /**
243
+ * The slugs the server skipped, named the way the panel names everything else.
244
+ *
245
+ * `facet_meta.skipped` is a list of index slugs (`power_w`); the sentence that
246
+ * reports them was printing exactly that, so a shopper read "These filters
247
+ * were not counted for this search: power_w". The category schema is already
248
+ * in this component for the option labels — it names these too. A slug the
249
+ * schema does not know stays as it is, because a made-up name would be worse
250
+ * than an honest identifier.
251
+ */
252
+ function skippedNames(
253
+ slugs: readonly string[],
254
+ features: readonly FeatureDef[] | undefined,
255
+ t: (key: string) => string
256
+ ): string {
257
+ return slugs
258
+ .map((slug) => {
259
+ const feature = features?.find((candidate) => candidate.slug === slug);
260
+ if (feature === undefined) return slug;
261
+ const name = t(featureName(feature));
262
+ return name.length > 0 ? name : slug;
263
+ })
264
+ .join(", ");
265
+ }
266
+
70
267
  export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
71
268
  const t = useT();
72
- const describe = useDescribeFlowError();
269
+ const { state } = useSearchState();
270
+ const ranges = buildRangeGroups({
271
+ state,
272
+ ...(props.categoryFeatures !== undefined
273
+ ? { categoryFeatures: props.categoryFeatures }
274
+ : {}),
275
+ t,
276
+ });
73
277
 
74
278
  return (
75
- <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
279
+ <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
76
280
  <FacetPanel
77
281
  {...(props.categoryFeatures !== undefined
78
282
  ? { categoryFeatures: props.categoryFeatures }
@@ -81,14 +285,17 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
81
285
  {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
82
286
  >
83
287
  {(bag) => (
84
- <Flex vertical gap={12} data-testid="search-facets">
85
- <Flex justify="space-between" align="center" gap={8}>
86
- <Typography.Title level={5} style={{ margin: 0 }}>
87
- {t(SEARCH_I18N_KEYS.facetsTitle)}
88
- </Typography.Title>
288
+ <Flex vertical gap={spacing[3]} data-testid="search-facets">
289
+ <Flex justify="space-between" align="center" gap={spacing[2]}>
290
+ {props.heading === null ? (
291
+ <span />
292
+ ) : (
293
+ <Typography.Title level={5} style={{ margin: 0 }}>
294
+ {props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
295
+ </Typography.Title>
296
+ )}
89
297
  {bag.activeFilters > 0 && (
90
298
  <Button
91
- size="small"
92
299
  onClick={bag.clearAll}
93
300
  data-analytics="none"
94
301
  data-analytics-reason="a filter is a read, not a flow step"
@@ -99,12 +306,30 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
99
306
  )}
100
307
  </Flex>
101
308
 
309
+ <CategoryFilter
310
+ {...(props.renderCategoryFilter !== undefined
311
+ ? { render: props.renderCategoryFilter }
312
+ : {})}
313
+ />
314
+ <LanguageSelect
315
+ {...(props.languages !== undefined ? { languages: props.languages } : {})}
316
+ />
317
+ <GeoFilter
318
+ {...(props.renderGeoFilter !== undefined
319
+ ? { render: props.renderGeoFilter }
320
+ : {})}
321
+ />
322
+
323
+ {/* Honesty flags, not failures: the counts ARE approximate and
324
+ those slugs WERE skipped, and a red box would teach a person
325
+ the page is broken. `ErrorAlert` is for a read that did not
326
+ happen; this is a read that happened with a caveat. */}
102
327
  {bag.approximate && (
103
328
  <Alert
104
329
  type="info"
105
330
  showIcon
106
331
  data-testid="facets-approximate"
107
- message={t(SEARCH_I18N_KEYS.facetsApproximate)}
332
+ title={t(SEARCH_I18N_KEYS.facetsApproximate)}
108
333
  />
109
334
  )}
110
335
  {bag.skipped.length > 0 && (
@@ -112,63 +337,71 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
112
337
  type="warning"
113
338
  showIcon
114
339
  data-testid="facets-skipped"
115
- message={t(SEARCH_I18N_KEYS.facetsSkipped, {
116
- slugs: bag.skipped.join(", "),
340
+ title={t(SEARCH_I18N_KEYS.facetsSkipped, {
341
+ slugs: skippedNames(bag.skipped, props.categoryFeatures, t),
117
342
  })}
118
343
  />
119
344
  )}
120
345
 
121
- {matchList(bag.state, {
122
- loading: () => (
123
- <Flex justify="center" style={{ padding: 16 }}>
124
- <Spin data-testid="facets-loading" />
125
- </Flex>
126
- ),
127
- failed: (error) => (
346
+ {ranges.length > 0 && (
347
+ <Flex vertical gap={spacing[3]} data-testid="search-ranges">
348
+ {ranges.map((group) => (
349
+ <RangeFilterRow
350
+ key={group.slug}
351
+ group={group}
352
+ onApply={bag.setRange}
353
+ />
354
+ ))}
355
+ </Flex>
356
+ )}
357
+
358
+ {ranges.length > 0 && <Divider style={{ margin: 0 }} />}
359
+
360
+ <LoadList
361
+ state={bag.state}
362
+ testId="facets"
363
+ skeletonRows={4}
364
+ empty={<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />}
365
+ failed={(error) => (
128
366
  <ErrorAlert
129
367
  testId="facets-failed"
130
- error={{
131
- ...describe(toFlowError(error)),
132
- message: t(SEARCH_I18N_KEYS.facetsLoadFailed),
133
- }}
134
- />
135
- ),
136
- empty: () => (
137
- <Empty
138
- data-testid="facets-empty"
139
- description={t(SEARCH_I18N_KEYS.facetsEmpty)}
368
+ thrown={error}
369
+ message={t(SEARCH_I18N_KEYS.facetsLoadFailed)}
140
370
  />
141
- ),
142
- ready: (groups) => (
143
- <Flex vertical gap={16}>
144
- {groups.map((group) => (
145
- <Flex
146
- vertical
147
- gap={4}
148
- key={group.slug}
149
- data-testid={`facet-group-${group.slug}`}
150
- data-counted={group.counted ? "true" : "false"}
151
- >
152
- <Typography.Text strong>{group.label}</Typography.Text>
153
- {group.options.map((option) => (
154
- <OptionRow
155
- key={option.value}
156
- group={group}
157
- option={option}
158
- onToggle={bag.toggle}
159
- />
160
- ))}
161
- </Flex>
162
- ))}
163
- <Typography.Text type="secondary" style={{ fontSize: 12 }}>
371
+ )}
372
+ >
373
+ {(groups) => (
374
+ <Flex vertical gap={spacing[4]}>
375
+ {/* A group with no options is a heading with nothing under
376
+ it. `power_w` arrives in `skipped` and in no facet map,
377
+ so it produced exactly that — "Power" printed twice on
378
+ the desktop panel, once as the range row and once as a
379
+ label over air. The skipped Alert above already names it;
380
+ a heading with no control under it names nothing. */}
381
+ {/* Each group draws itself the way its own schema says:
382
+ pills for a single-choice facet, indented children for a
383
+ hierarchical one, a fold for a long one. The panel does
384
+ not decide — `facetGroupShape` reads the same config keys
385
+ the attributes editor reads, so a facet cannot look one
386
+ way here and another way in the composer. */}
387
+ {groups
388
+ .filter((group) => group.options.length > 0)
389
+ .map((group) => (
390
+ <FacetGroupControl
391
+ key={group.slug}
392
+ group={group}
393
+ onToggle={bag.toggle}
394
+ />
395
+ ))}
396
+ <Typography.Text type="secondary">
164
397
  {t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
165
398
  </Typography.Text>
166
399
  </Flex>
167
- ),
168
- })}
400
+ )}
401
+ </LoadList>
169
402
  </Flex>
170
403
  )}
171
404
  </FacetPanel>
172
- </SearchSkinTheme>
405
+ </SkinTheme>
173
406
  );
174
407
  }