@stapel/search-react 0.4.0 → 0.7.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 (139) hide show
  1. package/CHANGELOG.md +151 -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/FacetPanelPane.d.ts +54 -3
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +119 -19
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/LanguageSelect.d.ts +9 -0
  12. package/dist/default/LanguageSelect.d.ts.map +1 -0
  13. package/dist/default/LanguageSelect.js +72 -0
  14. package/dist/default/LanguageSelect.js.map +1 -0
  15. package/dist/default/PageSizeSelect.d.ts +29 -0
  16. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  17. package/dist/default/PageSizeSelect.js +25 -0
  18. package/dist/default/PageSizeSelect.js.map +1 -0
  19. package/dist/default/RangeFilterRow.d.ts +12 -0
  20. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  21. package/dist/default/RangeFilterRow.js +75 -0
  22. package/dist/default/RangeFilterRow.js.map +1 -0
  23. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  24. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  25. package/dist/default/RankingDisclosurePane.js +15 -15
  26. package/dist/default/RankingDisclosurePane.js.map +1 -1
  27. package/dist/default/SearchBox.d.ts +13 -0
  28. package/dist/default/SearchBox.d.ts.map +1 -0
  29. package/dist/default/SearchBox.js +67 -0
  30. package/dist/default/SearchBox.js.map +1 -0
  31. package/dist/default/SearchPage.d.ts +54 -16
  32. package/dist/default/SearchPage.d.ts.map +1 -1
  33. package/dist/default/SearchPage.js +155 -8
  34. package/dist/default/SearchPage.js.map +1 -1
  35. package/dist/default/SearchResultCard.d.ts +19 -18
  36. package/dist/default/SearchResultCard.d.ts.map +1 -1
  37. package/dist/default/SearchResultCard.js +178 -5
  38. package/dist/default/SearchResultCard.js.map +1 -1
  39. package/dist/default/SearchResultsPane.d.ts +42 -3
  40. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  41. package/dist/default/SearchResultsPane.js +38 -21
  42. package/dist/default/SearchResultsPane.js.map +1 -1
  43. package/dist/default/SortSelect.d.ts +10 -2
  44. package/dist/default/SortSelect.d.ts.map +1 -1
  45. package/dist/default/SortSelect.js +27 -24
  46. package/dist/default/SortSelect.js.map +1 -1
  47. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  48. package/dist/default/UrlIssueNotice.js +19 -1
  49. package/dist/default/UrlIssueNotice.js.map +1 -1
  50. package/dist/default/index.d.ts +32 -15
  51. package/dist/default/index.d.ts.map +1 -1
  52. package/dist/default/index.js +26 -12
  53. package/dist/default/index.js.map +1 -1
  54. package/dist/default/sortLabels.d.ts +5 -0
  55. package/dist/default/sortLabels.d.ts.map +1 -0
  56. package/dist/default/sortLabels.js +32 -0
  57. package/dist/default/sortLabels.js.map +1 -0
  58. package/dist/headless/FacetPanel.d.ts +17 -0
  59. package/dist/headless/FacetPanel.d.ts.map +1 -1
  60. package/dist/headless/FacetPanel.js +23 -2
  61. package/dist/headless/FacetPanel.js.map +1 -1
  62. package/dist/headless/useAppliedCount.d.ts +8 -0
  63. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  64. package/dist/headless/useAppliedCount.js +30 -0
  65. package/dist/headless/useAppliedCount.js.map +1 -0
  66. package/dist/headless/useScorerNames.d.ts +4 -0
  67. package/dist/headless/useScorerNames.d.ts.map +1 -0
  68. package/dist/headless/useScorerNames.js +34 -0
  69. package/dist/headless/useScorerNames.js.map +1 -0
  70. package/dist/headless/useSearchBox.d.ts +44 -0
  71. package/dist/headless/useSearchBox.d.ts.map +1 -0
  72. package/dist/headless/useSearchBox.js +116 -0
  73. package/dist/headless/useSearchBox.js.map +1 -0
  74. package/dist/i18n/es.d.ts.map +1 -1
  75. package/dist/i18n/es.js +30 -4
  76. package/dist/i18n/es.js.map +1 -1
  77. package/dist/i18n/keys.d.ts +35 -0
  78. package/dist/i18n/keys.d.ts.map +1 -1
  79. package/dist/i18n/keys.js +72 -4
  80. package/dist/i18n/keys.js.map +1 -1
  81. package/dist/i18n/ru.d.ts.map +1 -1
  82. package/dist/i18n/ru.js +34 -4
  83. package/dist/i18n/ru.js.map +1 -1
  84. package/dist/index.d.ts +7 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +5 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/model/queries.d.ts +26 -2
  89. package/dist/model/queries.d.ts.map +1 -1
  90. package/dist/model/queries.js +38 -1
  91. package/dist/model/queries.js.map +1 -1
  92. package/dist/state/limits.d.ts +44 -0
  93. package/dist/state/limits.d.ts.map +1 -0
  94. package/dist/state/limits.js +44 -0
  95. package/dist/state/limits.js.map +1 -0
  96. package/dist/state/ranges.d.ts +52 -0
  97. package/dist/state/ranges.d.ts.map +1 -0
  98. package/dist/state/ranges.js +108 -0
  99. package/dist/state/ranges.js.map +1 -0
  100. package/llms.txt +15 -4
  101. package/manifest.json +238 -21
  102. package/nav-manifest.json +1 -1
  103. package/package.json +15 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/default/DegradationNotice.tsx +34 -12
  106. package/src/default/FacetPanelPane.tsx +343 -64
  107. package/src/default/LanguageSelect.tsx +93 -0
  108. package/src/default/PageSizeSelect.tsx +80 -0
  109. package/src/default/RangeFilterRow.tsx +148 -0
  110. package/src/default/RankingDisclosurePane.tsx +67 -35
  111. package/src/default/SearchBox.tsx +130 -0
  112. package/src/default/SearchPage.tsx +351 -44
  113. package/src/default/SearchResultCard.tsx +231 -24
  114. package/src/default/SearchResultsPane.tsx +120 -70
  115. package/src/default/SortSelect.tsx +50 -37
  116. package/src/default/UrlIssueNotice.tsx +21 -3
  117. package/src/default/index.ts +38 -15
  118. package/src/default/sortLabels.ts +34 -0
  119. package/src/headless/FacetPanel.tsx +30 -2
  120. package/src/headless/useAppliedCount.ts +41 -0
  121. package/src/headless/useScorerNames.ts +36 -0
  122. package/src/headless/useSearchBox.ts +189 -0
  123. package/src/i18n/es.ts +37 -4
  124. package/src/i18n/keys.ts +83 -4
  125. package/src/i18n/ru.ts +41 -4
  126. package/src/index.ts +22 -2
  127. package/src/model/queries.ts +51 -1
  128. package/src/state/limits.ts +50 -0
  129. package/src/state/ranges.ts +144 -0
  130. package/dist/default/ErrorAlert.d.ts +0 -9
  131. package/dist/default/ErrorAlert.d.ts.map +0 -1
  132. package/dist/default/ErrorAlert.js +0 -27
  133. package/dist/default/ErrorAlert.js.map +0 -1
  134. package/dist/default/theme.d.ts +0 -24
  135. package/dist/default/theme.d.ts.map +0 -1
  136. package/dist/default/theme.js +0 -39
  137. package/dist/default/theme.js.map +0 -1
  138. package/src/default/ErrorAlert.tsx +0 -46
  139. 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,23 +18,88 @@
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
+ Checkbox,
41
+ Divider,
42
+ Flex,
43
+ InputNumber,
44
+ Typography,
45
+ } from "antd";
46
+ import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
47
+ import {
48
+ EmptyState,
49
+ ErrorAlert,
50
+ LoadList,
51
+ SkinTheme,
52
+ } from "@stapel/tokens-antd/skin";
53
+ import { spacing } from "@stapel/tokens";
54
+ import { featureName } from "@stapel/attributes-react";
19
55
  import type { FeatureDef } from "@stapel/attributes-react";
56
+ import type { SearchGeo } from "../api/types.js";
20
57
  import { FacetPanel } from "../headless/FacetPanel.js";
58
+ import { useSearchState } from "../headless/SearchStateProvider.js";
21
59
  import type { FacetGroup, FacetOption } from "../state/facets.js";
60
+ import { buildRangeGroups } from "../state/ranges.js";
22
61
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
23
- import { ErrorAlert } from "./ErrorAlert.js";
24
- import { SearchSkinTheme } from "./theme.js";
62
+ import { LanguageSelect } from "./LanguageSelect.js";
63
+ import { RangeFilterRow } from "./RangeFilterRow.js";
25
64
  import type { ThemeModeProp } from "./types.js";
26
65
 
66
+ /** What a host's category control is handed. */
67
+ export interface CategoryFilterSlotProps {
68
+ /** The `root/leaf` path the search is narrowed to, if any. */
69
+ readonly value: string | undefined;
70
+ /** `null` widens the search back to the whole catalogue. */
71
+ readonly onChange: (path: string | null) => void;
72
+ }
73
+
74
+ /** What a host's location control is handed. */
75
+ export interface GeoFilterSlotProps {
76
+ readonly value: SearchGeo | undefined;
77
+ /** `null` clears the location constraint. */
78
+ readonly onChange: (geo: SearchGeo | null) => void;
79
+ }
80
+
27
81
  export interface FacetPanelPaneProps extends ThemeModeProp {
28
- /** The category's feature schema — the source of option LABELS. */
82
+ /** The category's feature schema — the source of option LABELS and of which
83
+ * slugs get a numeric range row. */
29
84
  readonly categoryFeatures?: readonly FeatureDef[];
30
85
  readonly locale?: string;
31
86
  readonly enabled?: boolean;
87
+ /** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
88
+ * to a path). Unfilled, an active category still gets a "clear" control. */
89
+ readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
90
+ /** The location control (`geo-react`). Unfilled, a location that arrived in
91
+ * the URL still gets its radius and a "clear" control. */
92
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
93
+ /** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
94
+ readonly languages?: readonly string[];
95
+ /**
96
+ * What the panel calls itself. `null` draws no title at all — for a surface
97
+ * that has ALREADY named it, which the phone sheet has: its dialog title and
98
+ * the panel's own heading both said "Filters", one under the other, in every
99
+ * shot of the open sheet. The row itself stays either way, because the
100
+ * "Clear all" control lives in it.
101
+ */
102
+ readonly heading?: ReactNode;
32
103
  }
33
104
 
34
105
  function OptionRow(props: {
@@ -39,7 +110,7 @@ function OptionRow(props: {
39
110
  const t = useT();
40
111
  const { option, group } = props;
41
112
  return (
42
- <Flex justify="space-between" align="center" gap={8}>
113
+ <Flex justify="space-between" align="center" gap={spacing[2]}>
43
114
  <Checkbox
44
115
  checked={option.selected}
45
116
  data-testid={`facet-option-${group.slug}-${option.value}`}
@@ -52,9 +123,12 @@ function OptionRow(props: {
52
123
  {option.label}
53
124
  </Checkbox>
54
125
  {option.count === null ? (
55
- <Tag data-testid={`facet-count-${group.slug}-${option.value}`}>
126
+ <Typography.Text
127
+ type="secondary"
128
+ data-testid={`facet-count-${group.slug}-${option.value}`}
129
+ >
56
130
  {t(SEARCH_I18N_KEYS.facetsNotCounted)}
57
- </Tag>
131
+ </Typography.Text>
58
132
  ) : (
59
133
  <Typography.Text
60
134
  type="secondary"
@@ -67,12 +141,182 @@ function OptionRow(props: {
67
141
  );
68
142
  }
69
143
 
144
+ /** The category constraint: the host's control, or the door out of it. */
145
+ function CategoryFilter(props: {
146
+ render?: (slot: CategoryFilterSlotProps) => ReactNode;
147
+ }): ReactElement | null {
148
+ const t = useT();
149
+ const { state, setCategory } = useSearchState();
150
+ const value = state.category;
151
+
152
+ if (props.render !== undefined) {
153
+ return (
154
+ <Flex vertical gap={spacing[1]} data-testid="search-category">
155
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
156
+ {props.render({
157
+ value,
158
+ onChange: (path) => {
159
+ setCategory(path);
160
+ },
161
+ })}
162
+ </Flex>
163
+ );
164
+ }
165
+
166
+ if (value === undefined) {
167
+ return <SlotPlaceholder name="renderCategoryFilter" data-testid="search-category-slot" />;
168
+ }
169
+
170
+ return (
171
+ <Flex vertical gap={spacing[1]} data-testid="search-category">
172
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
173
+ <Typography.Text type="secondary">
174
+ {t(SEARCH_I18N_KEYS.categoryCurrent, { path: value })}
175
+ </Typography.Text>
176
+ <Button
177
+ style={{ alignSelf: "flex-start" }}
178
+ data-testid="search-category-clear"
179
+ data-analytics="none"
180
+ data-analytics-reason="a filter is a read, not a flow step"
181
+ onClick={() => {
182
+ setCategory(null);
183
+ }}
184
+ >
185
+ {t(SEARCH_I18N_KEYS.categoryClear)}
186
+ </Button>
187
+ </Flex>
188
+ );
189
+ }
190
+
191
+ /**
192
+ * The location constraint.
193
+ *
194
+ * SETTING a centre needs a geocoder, which is the deployment's and
195
+ * `geo-react`'s. ADJUSTING one that already exists does not: the radius is a
196
+ * number in the URL, and a link shared with `lat/lon/radius_km` is a link this
197
+ * panel can widen, tighten and clear without knowing what a map is. That is the
198
+ * difference between a slot and a hole.
199
+ */
200
+ function GeoFilter(props: {
201
+ render?: (slot: GeoFilterSlotProps) => ReactNode;
202
+ }): ReactElement | null {
203
+ const t = useT();
204
+ const { state, setGeo } = useSearchState();
205
+ const geo = state.geo;
206
+
207
+ const slot =
208
+ props.render !== undefined
209
+ ? props.render({
210
+ value: geo,
211
+ onChange: (next) => {
212
+ setGeo(next);
213
+ },
214
+ })
215
+ : geo === undefined
216
+ ? <SlotPlaceholder name="renderGeoFilter" data-testid="search-geo-slot" />
217
+ : null;
218
+
219
+ if (geo === undefined) {
220
+ // An unfilled slot is a NAMED hole in development and nothing at all in a
221
+ // production build — so the heading has to follow the placeholder rather
222
+ // than outlive it. It did not, and the live desktop panel printed
223
+ // "Location" over empty space with no location control under it
224
+ // (class NC-ORPHANFIELD): a label is a promise that a control follows.
225
+ if (props.render === undefined && !isDevBuild()) return null;
226
+ return slot === null ? null : (
227
+ <Flex vertical gap={spacing[1]} data-testid="search-geo">
228
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
229
+ {slot}
230
+ </Flex>
231
+ );
232
+ }
233
+
234
+ return (
235
+ <Flex vertical gap={spacing[1]} data-testid="search-geo">
236
+ <Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
237
+ {slot}
238
+ <Typography.Text type="secondary" data-testid="search-geo-summary">
239
+ {geo.kind === "bbox"
240
+ ? t(SEARCH_I18N_KEYS.geoBox)
241
+ : t(SEARCH_I18N_KEYS.geoCenter, {
242
+ lat: geo.lat.toFixed(3),
243
+ lon: geo.lon.toFixed(3),
244
+ })}
245
+ </Typography.Text>
246
+ {geo.kind === "center" && (
247
+ <Flex gap={spacing[2]} align="center" wrap>
248
+ <Typography.Text type="secondary" aria-hidden="true">
249
+ {t(SEARCH_I18N_KEYS.geoRadiusLabel)}
250
+ </Typography.Text>
251
+ <InputNumber
252
+ min={1}
253
+ value={geo.radiusKm ?? null}
254
+ aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
255
+ data-testid="search-geo-radius"
256
+ onChange={(value) => {
257
+ setGeo({
258
+ kind: "center",
259
+ lat: geo.lat,
260
+ lon: geo.lon,
261
+ ...(typeof value === "number" ? { radiusKm: value } : {}),
262
+ });
263
+ }}
264
+ />
265
+ </Flex>
266
+ )}
267
+ <Button
268
+ style={{ alignSelf: "flex-start" }}
269
+ data-testid="search-geo-clear"
270
+ data-analytics="none"
271
+ data-analytics-reason="a filter is a read, not a flow step"
272
+ onClick={() => {
273
+ setGeo(null);
274
+ }}
275
+ >
276
+ {t(SEARCH_I18N_KEYS.geoClear)}
277
+ </Button>
278
+ </Flex>
279
+ );
280
+ }
281
+
282
+ /**
283
+ * The slugs the server skipped, named the way the panel names everything else.
284
+ *
285
+ * `facet_meta.skipped` is a list of index slugs (`power_w`); the sentence that
286
+ * reports them was printing exactly that, so a shopper read "These filters
287
+ * were not counted for this search: power_w". The category schema is already
288
+ * in this component for the option labels — it names these too. A slug the
289
+ * schema does not know stays as it is, because a made-up name would be worse
290
+ * than an honest identifier.
291
+ */
292
+ function skippedNames(
293
+ slugs: readonly string[],
294
+ features: readonly FeatureDef[] | undefined,
295
+ t: (key: string) => string
296
+ ): string {
297
+ return slugs
298
+ .map((slug) => {
299
+ const feature = features?.find((candidate) => candidate.slug === slug);
300
+ if (feature === undefined) return slug;
301
+ const name = t(featureName(feature));
302
+ return name.length > 0 ? name : slug;
303
+ })
304
+ .join(", ");
305
+ }
306
+
70
307
  export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
71
308
  const t = useT();
72
- const describe = useDescribeFlowError();
309
+ const { state } = useSearchState();
310
+ const ranges = buildRangeGroups({
311
+ state,
312
+ ...(props.categoryFeatures !== undefined
313
+ ? { categoryFeatures: props.categoryFeatures }
314
+ : {}),
315
+ t,
316
+ });
73
317
 
74
318
  return (
75
- <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
319
+ <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
76
320
  <FacetPanel
77
321
  {...(props.categoryFeatures !== undefined
78
322
  ? { categoryFeatures: props.categoryFeatures }
@@ -81,14 +325,17 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
81
325
  {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
82
326
  >
83
327
  {(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>
328
+ <Flex vertical gap={spacing[3]} data-testid="search-facets">
329
+ <Flex justify="space-between" align="center" gap={spacing[2]}>
330
+ {props.heading === null ? (
331
+ <span />
332
+ ) : (
333
+ <Typography.Title level={5} style={{ margin: 0 }}>
334
+ {props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
335
+ </Typography.Title>
336
+ )}
89
337
  {bag.activeFilters > 0 && (
90
338
  <Button
91
- size="small"
92
339
  onClick={bag.clearAll}
93
340
  data-analytics="none"
94
341
  data-analytics-reason="a filter is a read, not a flow step"
@@ -99,12 +346,30 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
99
346
  )}
100
347
  </Flex>
101
348
 
349
+ <CategoryFilter
350
+ {...(props.renderCategoryFilter !== undefined
351
+ ? { render: props.renderCategoryFilter }
352
+ : {})}
353
+ />
354
+ <LanguageSelect
355
+ {...(props.languages !== undefined ? { languages: props.languages } : {})}
356
+ />
357
+ <GeoFilter
358
+ {...(props.renderGeoFilter !== undefined
359
+ ? { render: props.renderGeoFilter }
360
+ : {})}
361
+ />
362
+
363
+ {/* Honesty flags, not failures: the counts ARE approximate and
364
+ those slugs WERE skipped, and a red box would teach a person
365
+ the page is broken. `ErrorAlert` is for a read that did not
366
+ happen; this is a read that happened with a caveat. */}
102
367
  {bag.approximate && (
103
368
  <Alert
104
369
  type="info"
105
370
  showIcon
106
371
  data-testid="facets-approximate"
107
- message={t(SEARCH_I18N_KEYS.facetsApproximate)}
372
+ title={t(SEARCH_I18N_KEYS.facetsApproximate)}
108
373
  />
109
374
  )}
110
375
  {bag.skipped.length > 0 && (
@@ -112,63 +377,77 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
112
377
  type="warning"
113
378
  showIcon
114
379
  data-testid="facets-skipped"
115
- message={t(SEARCH_I18N_KEYS.facetsSkipped, {
116
- slugs: bag.skipped.join(", "),
380
+ title={t(SEARCH_I18N_KEYS.facetsSkipped, {
381
+ slugs: skippedNames(bag.skipped, props.categoryFeatures, t),
117
382
  })}
118
383
  />
119
384
  )}
120
385
 
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) => (
386
+ {ranges.length > 0 && (
387
+ <Flex vertical gap={spacing[3]} data-testid="search-ranges">
388
+ {ranges.map((group) => (
389
+ <RangeFilterRow
390
+ key={group.slug}
391
+ group={group}
392
+ onApply={bag.setRange}
393
+ />
394
+ ))}
395
+ </Flex>
396
+ )}
397
+
398
+ {ranges.length > 0 && <Divider style={{ margin: 0 }} />}
399
+
400
+ <LoadList
401
+ state={bag.state}
402
+ testId="facets"
403
+ skeletonRows={4}
404
+ empty={<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />}
405
+ failed={(error) => (
128
406
  <ErrorAlert
129
407
  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)}
408
+ thrown={error}
409
+ message={t(SEARCH_I18N_KEYS.facetsLoadFailed)}
140
410
  />
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 }}>
411
+ )}
412
+ >
413
+ {(groups) => (
414
+ <Flex vertical gap={spacing[4]}>
415
+ {/* A group with no options is a heading with nothing under
416
+ it. `power_w` arrives in `skipped` and in no facet map,
417
+ so it produced exactly that — "Power" printed twice on
418
+ the desktop panel, once as the range row and once as a
419
+ label over air. The skipped Alert above already names it;
420
+ a heading with no control under it names nothing. */}
421
+ {groups
422
+ .filter((group) => group.options.length > 0)
423
+ .map((group) => (
424
+ <Flex
425
+ vertical
426
+ gap={spacing[1]}
427
+ key={group.slug}
428
+ data-testid={`facet-group-${group.slug}`}
429
+ data-counted={group.counted ? "true" : "false"}
430
+ >
431
+ <Typography.Text strong>{group.label}</Typography.Text>
432
+ {group.options.map((option) => (
433
+ <OptionRow
434
+ key={option.value}
435
+ group={group}
436
+ option={option}
437
+ onToggle={bag.toggle}
438
+ />
439
+ ))}
440
+ </Flex>
441
+ ))}
442
+ <Typography.Text type="secondary">
164
443
  {t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
165
444
  </Typography.Text>
166
445
  </Flex>
167
- ),
168
- })}
446
+ )}
447
+ </LoadList>
169
448
  </Flex>
170
449
  )}
171
450
  </FacetPanel>
172
- </SearchSkinTheme>
451
+ </SkinTheme>
173
452
  );
174
453
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The query's LANGUAGE — `lang`, and the reason it is a filter rather than a
3
+ * display preference.
4
+ *
5
+ * `lang` does two things server-side (`query.py`): it picks the ANALYZER (so a
6
+ * Russian word stems as Russian rather than as an unknown token) and it
7
+ * NARROWS the corpus to documents indexed in that language. Getting it wrong therefore changes
8
+ * which results exist, not how they are written — which is why the control
9
+ * lives in the filter panel beside the facets and not in a settings menu.
10
+ *
11
+ * ── Which languages a deployment has is not this pair's fact ───────────────
12
+ *
13
+ * The index's languages come from what has been indexed; there is no endpoint
14
+ * that lists them. So the host names them (`languages`), and the labels come
15
+ * from `Intl.DisplayNames` in the reader's own locale — each language named
16
+ * the way THIS reader would name it — rather than from a translation table this
17
+ * pair would have to carry for every language a deployment might index.
18
+ *
19
+ * With no `languages` prop the control renders only when the URL already
20
+ * carries one, and then only to REMOVE it: a constraint that arrived in a
21
+ * shared link must always have a control that widens it again.
22
+ */
23
+ import { useMemo } from "react";
24
+ import type { ReactElement } from "react";
25
+ import { Flex, Select, Typography } from "antd";
26
+ import { useOptionalI18n, useT } from "@stapel/core";
27
+ import { spacing } from "@stapel/tokens";
28
+ import { useSearchState } from "../headless/SearchStateProvider.js";
29
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
30
+
31
+ /** Width of the language select — a language name plus room for a long one. */
32
+ export const LANGUAGE_SELECT_MIN_WIDTH = 176;
33
+
34
+ export interface LanguageSelectProps {
35
+ /** BCP-47 tags the deployment indexes (e.g. `["ru", "en"]`). */
36
+ readonly languages?: readonly string[];
37
+ }
38
+
39
+ /**
40
+ * A language tag as a person reads it, in THEIR locale. Falls back to the tag
41
+ * itself where the runtime has no name for it — a tag is at least honest,
42
+ * where a blank option is unclickable.
43
+ */
44
+ function languageName(tag: string, locale: string): string {
45
+ try {
46
+ const names = new Intl.DisplayNames([locale], { type: "language" });
47
+ return names.of(tag) ?? tag;
48
+ } catch {
49
+ return tag;
50
+ }
51
+ }
52
+
53
+ export function LanguageSelect(props: LanguageSelectProps): ReactElement | null {
54
+ const t = useT();
55
+ // The reader's locale names the languages. Optional on purpose: every other
56
+ // control in this skin renders outside a provider (English floor) rather
57
+ // than throwing, and a language list is not the place to break that.
58
+ const locale = useOptionalI18n()?.locale ?? "en";
59
+ const { state, setLanguage } = useSearchState();
60
+ const active = state.lang;
61
+ const languages = props.languages;
62
+
63
+ const options = useMemo(() => {
64
+ const tags = [...(languages ?? [])];
65
+ if (active !== undefined && !tags.includes(active)) tags.push(active);
66
+ return [
67
+ { value: "", label: t(SEARCH_I18N_KEYS.languageAny) },
68
+ ...tags.map((tag) => ({ value: tag, label: languageName(tag, locale) })),
69
+ ];
70
+ }, [languages, active, locale, t]);
71
+
72
+ // Nothing offered and nothing applied: a select with one option ("any") is a
73
+ // control that cannot do anything, which §83 calls a dead control.
74
+ if ((languages ?? []).length === 0 && active === undefined) return null;
75
+
76
+ return (
77
+ <Flex gap={spacing[2]} align="center" wrap>
78
+ <Typography.Text type="secondary" aria-hidden="true">
79
+ {t(SEARCH_I18N_KEYS.languageLabel)}
80
+ </Typography.Text>
81
+ <Select<string>
82
+ data-testid="search-language"
83
+ aria-label={t(SEARCH_I18N_KEYS.languageLabel)}
84
+ style={{ minWidth: LANGUAGE_SELECT_MIN_WIDTH }}
85
+ value={active ?? ""}
86
+ onChange={(next) => {
87
+ setLanguage(next === "" ? null : next);
88
+ }}
89
+ options={options}
90
+ />
91
+ </Flex>
92
+ );
93
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * How many results a page carries — `limit`, the fifth of the nine state
3
+ * setters that had no control.
4
+ *
5
+ * The offered sizes are the pair's, not the schema's: `docs/schema.json`
6
+ * declares `limit` as a bare integer, and the backend clamps it to
7
+ * `1..MAX_PAGE_SIZE` (100) with `DEFAULT_PAGE_SIZE` 24 when it is absent. So
8
+ * the select offers a short ladder around that default and never sends
9
+ * something the server would have to clamp — a person who lands on a link
10
+ * carrying `limit=37` keeps it (it is offered as its own option) rather than
11
+ * being silently moved to 24, which would change what a shared link shows.
12
+ *
13
+ * A page size is a PREFERENCE, not a step through the results: `setLimit`
14
+ * replaces the history entry rather than pushing one, so Back still undoes the
15
+ * last filter and not the last time somebody widened the page.
16
+ */
17
+ import type { ReactElement } from "react";
18
+ import { Flex, Select, Typography } from "antd";
19
+ import { useT } from "@stapel/core";
20
+ import { fontSize, spacing } from "@stapel/tokens";
21
+ import { useSearchState } from "../headless/SearchStateProvider.js";
22
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
23
+
24
+ /** The shipped ladder, around stapel-search's own `DEFAULT_PAGE_SIZE` (24). */
25
+ export const SEARCH_PAGE_SIZES: readonly number[] = [12, 24, 48, 96];
26
+
27
+ /** Width of the size select — the widest option plus its caption. */
28
+ export const PAGE_SIZE_SELECT_MIN_WIDTH = 148;
29
+
30
+ export interface PageSizeSelectProps {
31
+ /** Override the offered ladder (a deployment with its own `MAX_PAGE_SIZE`). */
32
+ readonly sizes?: readonly number[];
33
+ /** What the server uses when the URL names none. Default 24. */
34
+ readonly defaultSize?: number;
35
+ }
36
+
37
+ export function PageSizeSelect(props: PageSizeSelectProps): ReactElement {
38
+ const t = useT();
39
+ const { state, setLimit } = useSearchState();
40
+ const ladder = props.sizes ?? SEARCH_PAGE_SIZES;
41
+ const active = state.limit ?? props.defaultSize ?? 24;
42
+ const offLadder = !ladder.includes(active);
43
+ const values = offLadder ? [...ladder, active].sort((a, b) => a - b) : ladder;
44
+
45
+ return (
46
+ <Flex vertical gap={spacing[1]} data-off-ladder={offLadder ? "true" : "false"}>
47
+ <Flex gap={spacing[2]} align="center">
48
+ <Typography.Text type="secondary" aria-hidden="true">
49
+ {t(SEARCH_I18N_KEYS.limitLabel)}
50
+ </Typography.Text>
51
+ <Select<number>
52
+ data-testid="search-limit"
53
+ aria-label={t(SEARCH_I18N_KEYS.limitLabel)}
54
+ style={{ minWidth: PAGE_SIZE_SELECT_MIN_WIDTH }}
55
+ value={active}
56
+ onChange={(next) => {
57
+ setLimit(next);
58
+ }}
59
+ options={values.map((size) => ({
60
+ value: size,
61
+ label: t(SEARCH_I18N_KEYS.limitOption, { count: size }),
62
+ }))}
63
+ />
64
+ </Flex>
65
+ {/* Keeping a link's own page size is a DECISION, and a decision nobody
66
+ is told about is indistinguishable from a default. "37 per page" in
67
+ the box looks like the deployment's ladder until this line says
68
+ where it came from. */}
69
+ {offLadder && (
70
+ <Typography.Text
71
+ type="secondary"
72
+ style={{ fontSize: fontSize.xs.fontSize }}
73
+ data-testid="search-limit-from-link"
74
+ >
75
+ {t(SEARCH_I18N_KEYS.limitFromLink)}
76
+ </Typography.Text>
77
+ )}
78
+ </Flex>
79
+ );
80
+ }