@stapel/search-react 0.7.0 → 0.9.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 (59) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/default/FacetGroupControl.d.ts +40 -0
  3. package/dist/default/FacetGroupControl.d.ts.map +1 -0
  4. package/dist/default/FacetGroupControl.js +176 -0
  5. package/dist/default/FacetGroupControl.js.map +1 -0
  6. package/dist/default/FacetPanelPane.d.ts +30 -0
  7. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  8. package/dist/default/FacetPanelPane.js +28 -16
  9. package/dist/default/FacetPanelPane.js.map +1 -1
  10. package/dist/default/FilterChips.d.ts +37 -0
  11. package/dist/default/FilterChips.d.ts.map +1 -0
  12. package/dist/default/FilterChips.js +203 -0
  13. package/dist/default/FilterChips.js.map +1 -0
  14. package/dist/default/SearchPage.d.ts +59 -0
  15. package/dist/default/SearchPage.d.ts.map +1 -1
  16. package/dist/default/SearchPage.js +60 -10
  17. package/dist/default/SearchPage.js.map +1 -1
  18. package/dist/default/SearchResultsPane.d.ts +19 -0
  19. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  20. package/dist/default/SearchResultsPane.js +15 -2
  21. package/dist/default/SearchResultsPane.js.map +1 -1
  22. package/dist/default/ViewSwitch.d.ts +66 -0
  23. package/dist/default/ViewSwitch.d.ts.map +1 -0
  24. package/dist/default/ViewSwitch.js +49 -0
  25. package/dist/default/ViewSwitch.js.map +1 -0
  26. package/dist/default/index.d.ts +12 -1
  27. package/dist/default/index.d.ts.map +1 -1
  28. package/dist/default/index.js +9 -1
  29. package/dist/default/index.js.map +1 -1
  30. package/dist/headless/SearchStateProvider.d.ts +35 -0
  31. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  32. package/dist/headless/SearchStateProvider.js +38 -3
  33. package/dist/headless/SearchStateProvider.js.map +1 -1
  34. package/dist/i18n/es.d.ts.map +1 -1
  35. package/dist/i18n/es.js +9 -1
  36. package/dist/i18n/es.js.map +1 -1
  37. package/dist/i18n/keys.d.ts +32 -1
  38. package/dist/i18n/keys.d.ts.map +1 -1
  39. package/dist/i18n/keys.js +42 -2
  40. package/dist/i18n/keys.js.map +1 -1
  41. package/dist/i18n/ru.d.ts.map +1 -1
  42. package/dist/i18n/ru.js +9 -1
  43. package/dist/i18n/ru.js.map +1 -1
  44. package/llms.txt +2 -1
  45. package/manifest.json +32 -3
  46. package/nav-manifest.json +1 -1
  47. package/package.json +8 -8
  48. package/src/analytics/generated/events.json +1 -1
  49. package/src/default/FacetGroupControl.tsx +312 -0
  50. package/src/default/FacetPanelPane.tsx +57 -63
  51. package/src/default/FilterChips.tsx +423 -0
  52. package/src/default/SearchPage.tsx +158 -16
  53. package/src/default/SearchResultsPane.tsx +39 -2
  54. package/src/default/ViewSwitch.tsx +147 -0
  55. package/src/default/index.ts +23 -1
  56. package/src/headless/SearchStateProvider.tsx +80 -3
  57. package/src/i18n/es.ts +10 -1
  58. package/src/i18n/keys.ts +44 -2
  59. package/src/i18n/ru.ts +10 -1
@@ -33,6 +33,20 @@
33
33
  * behind a "Filters (N)" button and opens as a bottom sheet through the shared
34
34
  * `SkinDialog`, with the count of what is applied on the button itself.
35
35
  *
36
+ * ── Where the search opens, and what it calls the place ───────────────────
37
+ *
38
+ * `defaultGeo` centres a fresh search on the visitor — a granted browser
39
+ * prompt, or the server's IP guess when there was none — and `geoLabel` says
40
+ * what that place is CALLED. Both are the host's to resolve: this page has a
41
+ * `lat` and a `lon` in its query string and no way on earth to turn them into
42
+ * "Berlin Mitte", which is exactly why it must not print them. A search
43
+ * package that grew a geocoder to say a nicer sentence would have taken on the
44
+ * whole of `geo-react` to avoid one bad line.
45
+ *
46
+ * The centring is applied once, only into a URL that carries no location, and
47
+ * never again after the visitor has said anything about location themselves —
48
+ * see {@link SearchStateProviderProps.defaultGeo}, which owns the rules.
49
+ *
36
50
  * ── The filter column is laid out only when there is something in it ──────
37
51
  *
38
52
  * `Col md={7}` was unconditional, and on a deployment whose search plan
@@ -59,6 +73,7 @@ import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
59
73
  import { useFacetPanel } from "../headless/FacetPanel.js";
60
74
  import { useAppliedCount } from "../headless/useAppliedCount.js";
61
75
  import type { ParseSearchStateOptions } from "../state/urlState.js";
76
+ import type { SearchGeo } from "../api/types.js";
62
77
  import { buildRangeGroups } from "../state/ranges.js";
63
78
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
64
79
  import { FacetPanelPane } from "./FacetPanelPane.js";
@@ -66,10 +81,13 @@ import type {
66
81
  CategoryFilterSlotProps,
67
82
  GeoFilterSlotProps,
68
83
  } from "./FacetPanelPane.js";
84
+ import { FilterChips } from "./FilterChips.js";
69
85
  import { PageSizeSelect } from "./PageSizeSelect.js";
70
86
  import { SearchBox } from "./SearchBox.js";
71
87
  import { SearchResultsPane } from "./SearchResultsPane.js";
72
88
  import { SortSelect } from "./SortSelect.js";
89
+ import { SEARCH_BUILTIN_VIEWS, ViewSwitch, resolveView } from "./ViewSwitch.js";
90
+ import type { SearchView } from "./ViewSwitch.js";
73
91
  import type { DegradationNoticeVariant } from "./DegradationNotice.js";
74
92
  import type { SearchCardRenderer } from "./SearchResultCard.js";
75
93
  import { UrlIssueNotice } from "./UrlIssueNotice.js";
@@ -91,10 +109,31 @@ export type SearchFiltersLayout = "column" | "sheet";
91
109
  */
92
110
  export const FILTERS_RAIL_WIDTH = 280;
93
111
 
94
- /** The rail: fixed, never squeezed, never grown. */
112
+ /**
113
+ * The rail: fixed width, never squeezed, never grown — and STICKY.
114
+ *
115
+ * A catalogue page is thirty cards long and the filters are at the top of it,
116
+ * so by the fourth row of results the controls that narrow the list are a
117
+ * screenful above the list they narrow: the only way to change a filter after
118
+ * scrolling was to scroll back. The rail now stays put while the results move
119
+ * under it, and scrolls INTERNALLY when its own content is taller than the
120
+ * window (`overflowY: auto` + a viewport-height cap), which is the one place a
121
+ * viewport measure is right — the sticky box's height IS the window's.
122
+ *
123
+ * `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
124
+ * height by default, and a stretched box has nothing to stick to.
125
+ */
95
126
  const RAIL: CSSProperties = {
96
127
  flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
97
128
  maxWidth: FILTERS_RAIL_WIDTH,
129
+ position: "sticky",
130
+ top: 0,
131
+ alignSelf: "flex-start",
132
+ maxHeight: "100dvh",
133
+ overflowY: "auto",
134
+ overscrollBehavior: "contain",
135
+ // Room for the focus ring of the last control against the scroll edge.
136
+ paddingBlockEnd: spacing[2],
98
137
  };
99
138
 
100
139
  /** The results take what is left. `minWidth: 0` so a long word inside a card
@@ -117,6 +156,24 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
117
156
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
118
157
  /** The location control slot (`geo-react`). */
119
158
  readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
159
+ /**
160
+ * What the current location is CALLED, in words — set once here and it
161
+ * reaches both filter surfaces, the desktop panel and the phone chip row.
162
+ * See {@link FacetPanelPaneProps.geoLabel}: this page never prints a
163
+ * coordinate, with or without it.
164
+ */
165
+ readonly geoLabel?: ReactNode;
166
+ /**
167
+ * Open the search centred where the visitor is, when the URL says nothing
168
+ * about location — see {@link SearchStateProviderProps.defaultGeo} for the
169
+ * four rules that keep it from overruling a link or a person.
170
+ *
171
+ * The host resolves the position; this page does not know what a map or a
172
+ * geocoder is and must not learn. A browser prompt (`usePermission`
173
+ * + `geolocation`) or the server's IP guess both arrive here as the same two
174
+ * numbers, and `geoLabel` is where the name of the place they mean comes in.
175
+ */
176
+ readonly defaultGeo?: SearchGeo | undefined;
120
177
  /** Container chrome under the results — e.g. the ranking-disclosure link. */
121
178
  readonly footer?: ReactNode;
122
179
  /**
@@ -152,6 +209,45 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
152
209
  readonly defaultFiltersOpen?: boolean;
153
210
  /** Offer a page-size control beside the sort. Default `true`. */
154
211
  readonly pageSize?: boolean;
212
+ /**
213
+ * The trail above the heading — "Home / Cars / Sedans".
214
+ *
215
+ * A SLOT, because a breadcrumb is a walk up the CATEGORY tree and the tree
216
+ * belongs to `categories-react`; a search package knows the `category`
217
+ * parameter's value and nothing about its ancestors. Rendered above the
218
+ * results heading, inside the results column, so it sits over the list it
219
+ * describes rather than over the whole two-column page.
220
+ */
221
+ readonly breadcrumb?: ReactNode;
222
+ /**
223
+ * The arrangements the view switch offers. Default: the pair's own list and
224
+ * grid. A deployment adds its own — `{ id: "map", labelKey, icon, render }`
225
+ * — and the switch treats it like the two that ship; see {@link SearchView}.
226
+ *
227
+ * A single view draws no switch at all.
228
+ */
229
+ readonly views?: readonly SearchView[];
230
+ /** Which arrangement the page opens in. Default: the first of `views`. */
231
+ readonly defaultView?: string;
232
+ /** Told when the arrangement changes, for a host that remembers it. The
233
+ * view is NOT url state — see `<ViewSwitch>` for why. */
234
+ readonly onViewChange?: (id: string) => void;
235
+ /**
236
+ * The action at the trailing end of the results toolbar — conventionally
237
+ * "notify me about new ones".
238
+ *
239
+ * A SLOT, and it cannot be anything else: saving a search and mailing its
240
+ * new hits is a subscription with an owner, a schedule and a consent record,
241
+ * none of which this pair has. What the pair CAN state is where such a
242
+ * control belongs and that the page keeps room for it.
243
+ */
244
+ readonly resultsAction?: ReactNode;
245
+ /**
246
+ * Heading level for the results caption. Default `1` — on a results SCREEN
247
+ * the list's name is the page's heading. A container that already prints its
248
+ * own `<h1>` above this page passes a lower level.
249
+ */
250
+ readonly resultsHeadingLevel?: 1 | 2 | 3 | 4 | 5;
155
251
  }
156
252
 
157
253
  interface SearchPageBodyProps {
@@ -162,6 +258,7 @@ interface SearchPageBodyProps {
162
258
  readonly languages?: readonly string[];
163
259
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
164
260
  readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
261
+ readonly geoLabel?: ReactNode;
165
262
  readonly footer?: ReactNode;
166
263
  readonly filtersHeader?: ReactNode;
167
264
  readonly resultsHeading?: ReactNode;
@@ -169,6 +266,12 @@ interface SearchPageBodyProps {
169
266
  readonly filtersLayout?: SearchFiltersLayout;
170
267
  readonly defaultFiltersOpen?: boolean;
171
268
  readonly pageSize?: boolean;
269
+ readonly breadcrumb?: ReactNode;
270
+ readonly views?: readonly SearchView[];
271
+ readonly defaultView?: string;
272
+ readonly onViewChange?: (id: string) => void;
273
+ readonly resultsAction?: ReactNode;
274
+ readonly resultsHeadingLevel?: 1 | 2 | 3 | 4 | 5;
172
275
  }
173
276
 
174
277
  /**
@@ -190,6 +293,18 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
190
293
  props.filtersLayout ?? (surface === "sheet" ? "sheet" : "column");
191
294
  const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
192
295
 
296
+ // How the results are ARRANGED. Component state, not URL state: it changes
297
+ // how the same answer is drawn, never what the answer is, so it must not
298
+ // rewrite the meaning of a link somebody shared (`<ViewSwitch>` §the view is
299
+ // not URL state).
300
+ const views = props.views ?? SEARCH_BUILTIN_VIEWS;
301
+ const [viewId, setViewId] = useState<string | undefined>(props.defaultView);
302
+ const view = resolveView(views, viewId) ?? { id: "", labelKey: "" };
303
+ const changeView = (next: string): void => {
304
+ setViewId(next);
305
+ props.onViewChange?.(next);
306
+ };
307
+
193
308
  /**
194
309
  * "Show 25 results", not "Show results".
195
310
  *
@@ -251,21 +366,29 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
251
366
  {...(props.renderGeoFilter !== undefined
252
367
  ? { renderGeoFilter: props.renderGeoFilter }
253
368
  : {})}
369
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
254
370
  />
255
371
  )}
256
372
  </Flex>
257
373
  );
258
374
 
375
+ // The toolbar over the results: how they are ARRANGED, how they are ORDERED,
376
+ // how many per page — and the surface's own action at the trailing end.
259
377
  const toolbar = (
260
378
  <Flex align="center" wrap gap={spacing[3]}>
379
+ <ViewSwitch views={views} value={view.id} onChange={changeView} />
261
380
  <SortSelect />
262
381
  {props.pageSize !== false && <PageSizeSelect />}
382
+ {props.resultsAction}
263
383
  </Flex>
264
384
  );
265
385
 
266
386
  const results = (
267
387
  <SearchResultsPane
268
388
  toolbar={toolbar}
389
+ headingLevel={props.resultsHeadingLevel ?? 1}
390
+ {...(view.render !== undefined ? { renderResults: view.render } : {})}
391
+ {...(view.layout !== undefined ? { layout: view.layout } : {})}
269
392
  {...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
270
393
  {...(props.footer !== undefined ? { footer: props.footer } : {})}
271
394
  {...(props.resultsHeading !== undefined
@@ -286,26 +409,30 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
286
409
  data-filters-layout={layout}
287
410
  >
288
411
  {props.searchBox !== false && <SearchBox />}
412
+ {props.breadcrumb !== undefined && (
413
+ <div data-testid="search-breadcrumb">{props.breadcrumb}</div>
414
+ )}
289
415
  <UrlIssueNotice />
290
416
 
291
417
  {showFilters && layout === "sheet" ? (
292
418
  <>
293
- <Button
294
- block
295
- data-testid="search-filters-open"
296
- data-analytics="none"
297
- data-analytics-reason="opening the filter sheet is a read, not a flow step"
298
- onClick={() => {
419
+ {/* The phone's filter row. It REPLACES the full-width "Filters (3)"
420
+ button that used to stand here: that button said how many
421
+ constraints were applied and not one word about WHICH, and put
422
+ every filter behind one tap onto a sheet you then had to scroll.
423
+ The chips state the filters on the page and the leading chip is
424
+ still the whole panel, for the person who wants all of it. */}
425
+ <FilterChips
426
+ onOpenAll={() => {
299
427
  setSheetOpen(true);
300
428
  }}
301
- >
302
- {/* "Filters (0)" is a count of nothing printed on the control that
303
- opens the thing that would produce one. The count appears when
304
- there is a count. */}
305
- {facets.activeFilters === 0
306
- ? t(SEARCH_I18N_KEYS.facetsTitle)
307
- : t(SEARCH_I18N_KEYS.filtersOpen, { count: facets.activeFilters })}
308
- </Button>
429
+ {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
430
+ {...(locale !== undefined ? { locale } : {})}
431
+ {...(props.renderGeoFilter !== undefined
432
+ ? { renderGeoFilter: props.renderGeoFilter }
433
+ : {})}
434
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
435
+ />
309
436
  <SkinDialog
310
437
  open={sheetOpen}
311
438
  onClose={() => {
@@ -361,6 +488,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
361
488
  languages,
362
489
  renderCategoryFilter,
363
490
  renderGeoFilter,
491
+ geoLabel,
492
+ defaultGeo,
364
493
  footer,
365
494
  filtersHeader,
366
495
  resultsHeading,
@@ -368,13 +497,19 @@ export function SearchPage(props: SearchPageProps): ReactElement {
368
497
  filtersLayout,
369
498
  defaultFiltersOpen,
370
499
  pageSize,
500
+ breadcrumb,
501
+ views,
502
+ defaultView,
503
+ onViewChange,
504
+ resultsAction,
505
+ resultsHeadingLevel,
371
506
  mode,
372
507
  ...parseOptions
373
508
  } = props;
374
509
 
375
510
  return (
376
511
  <SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
377
- <SearchStateProvider adapter={adapter} {...parseOptions}>
512
+ <SearchStateProvider adapter={adapter} defaultGeo={defaultGeo} {...parseOptions}>
378
513
  <SearchPageBody
379
514
  {...(renderCard !== undefined ? { renderCard } : {})}
380
515
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
@@ -383,6 +518,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
383
518
  {...(languages !== undefined ? { languages } : {})}
384
519
  {...(renderCategoryFilter !== undefined ? { renderCategoryFilter } : {})}
385
520
  {...(renderGeoFilter !== undefined ? { renderGeoFilter } : {})}
521
+ {...(geoLabel !== undefined ? { geoLabel } : {})}
386
522
  {...(footer !== undefined ? { footer } : {})}
387
523
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
388
524
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
@@ -390,6 +526,12 @@ export function SearchPage(props: SearchPageProps): ReactElement {
390
526
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
391
527
  {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
392
528
  {...(pageSize !== undefined ? { pageSize } : {})}
529
+ {...(breadcrumb !== undefined ? { breadcrumb } : {})}
530
+ {...(views !== undefined ? { views } : {})}
531
+ {...(defaultView !== undefined ? { defaultView } : {})}
532
+ {...(onViewChange !== undefined ? { onViewChange } : {})}
533
+ {...(resultsAction !== undefined ? { resultsAction } : {})}
534
+ {...(resultsHeadingLevel !== undefined ? { resultsHeadingLevel } : {})}
393
535
  />
394
536
  </SearchStateProvider>
395
537
  </SkinTheme>
@@ -45,6 +45,7 @@ import { DegradationNotice } from "./DegradationNotice.js";
45
45
  import type { DegradationNoticeVariant } from "./DegradationNotice.js";
46
46
  import { SearchResultCard } from "./SearchResultCard.js";
47
47
  import type { SearchCardRenderer } from "./SearchResultCard.js";
48
+ import type { SearchResultsLayout } from "./ViewSwitch.js";
48
49
  import type { ThemeModeProp } from "./types.js";
49
50
 
50
51
  /**
@@ -84,6 +85,20 @@ const RESULTS_GRID: CSSProperties = {
84
85
  alignItems: "stretch",
85
86
  };
86
87
 
88
+ /**
89
+ * The LIST arrangement: one wide row per result.
90
+ *
91
+ * The same grid with one track, deliberately — not a second layout mechanism.
92
+ * A list is a grid whose column count is one, and expressing it that way is
93
+ * what keeps the card slot, the gap and the stretch identical between the two
94
+ * arrangements, so switching views cannot change anything except the number of
95
+ * columns.
96
+ */
97
+ const RESULTS_LIST: CSSProperties = {
98
+ ...RESULTS_GRID,
99
+ gridTemplateColumns: "1fr",
100
+ };
101
+
87
102
  export interface SearchResultsPaneProps extends ThemeModeProp {
88
103
  /**
89
104
  * The card slot (spec §6.2 item 1). A storefront passes
@@ -135,6 +150,24 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
135
150
  /** Widest the column of results may grow (default {@link RESULTS_MAX_WIDTH});
136
151
  * `null` lets the container decide. */
137
152
  readonly maxWidth?: number | null;
153
+ /**
154
+ * How the loaded rows are arranged: as many card columns as fit
155
+ * (`"grid"`, the default) or one wide row each (`"list"`). The view SWITCH
156
+ * that flips this lives in `<ViewSwitch>`; the pane only draws.
157
+ *
158
+ * Ignored when `renderResults` replaces the arrangement entirely.
159
+ */
160
+ readonly layout?: SearchResultsLayout;
161
+ /**
162
+ * Heading level for the results caption. Default `4`, which is what every
163
+ * surface that EMBEDS this pane under its own title needs.
164
+ *
165
+ * `<SearchPage>` passes `1`: on a results SCREEN the list's name is the
166
+ * page's heading, and a page whose only heading is an `<h4>` has a document
167
+ * outline that starts three levels down — the thing a screen reader's
168
+ * heading list is for.
169
+ */
170
+ readonly headingLevel?: 1 | 2 | 3 | 4 | 5;
138
171
  }
139
172
 
140
173
  function Count(props: { bag: SearchResultsBag }): ReactElement | null {
@@ -217,7 +250,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
217
250
  <Flex vertical gap={spacing[4]}>
218
251
  <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
219
252
  <Typography.Title
220
- level={4}
253
+ level={props.headingLevel ?? 4}
221
254
  style={{ margin: 0 }}
222
255
  data-testid="search-results-heading"
223
256
  >
@@ -270,7 +303,11 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
270
303
  {renderResults !== undefined ? (
271
304
  renderResults(items)
272
305
  ) : (
273
- <div style={RESULTS_GRID} data-testid="search-results-grid">
306
+ <div
307
+ style={props.layout === "list" ? RESULTS_LIST : RESULTS_GRID}
308
+ data-testid="search-results-grid"
309
+ data-layout={props.layout ?? "grid"}
310
+ >
274
311
  {items.map((item: SearchItem) => (
275
312
  <div key={item.key}>
276
313
  {renderCard !== undefined ? (
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `<ViewSwitch>` — how the results are ARRANGED: a list, a grid, or something
3
+ * the deployment brought with it.
4
+ *
5
+ * ## Why two of the three views ship and the third is a slot
6
+ *
7
+ * A list and a grid are the SAME data in two arrangements: one column of wide
8
+ * rows, or as many card columns as fit. That is a layout decision and a search
9
+ * pair can take it. A MAP is not — it needs a tile source, a projection, a
10
+ * marker layer and a geocoder, all of which live in `geo-react` and none of
11
+ * which a search package may depend on. So the third view arrives as a
12
+ * {@link SearchView} the host declares, with its own `render`, and this
13
+ * component treats it exactly like the two it ships. A pair that hardcoded a
14
+ * "map" button and then had nothing to draw would be offering a control that
15
+ * cannot work — the defect class the fleet calls a silent slot.
16
+ *
17
+ * ## The view is not URL state, and that is deliberate
18
+ *
19
+ * `sort`, `page size`, every filter and the cursor are in the query string,
20
+ * because they change WHAT the answer is and a shared link has to carry them.
21
+ * The view changes only how the same answer is drawn. Putting it in the URL
22
+ * would make every arrangement flip a new history entry and would rewrite the
23
+ * meaning of a link somebody sent. The page holds it in component state and
24
+ * offers `defaultView`/`onViewChange` so a host that wants it remembered can
25
+ * persist it wherever it keeps preferences.
26
+ */
27
+ import type { ReactElement, ReactNode } from "react";
28
+ import { Segmented } from "antd";
29
+ import { useT } from "@stapel/core";
30
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
31
+ import type { SearchResultsRenderer } from "./SearchResultsPane.js";
32
+
33
+ /** How the pane's own arrangements lay a page of results out. */
34
+ export type SearchResultsLayout = "grid" | "list";
35
+
36
+ /** One arrangement offered by the switch. */
37
+ export interface SearchView {
38
+ /** Stable id — what `defaultView`/`onViewChange` speak in. */
39
+ readonly id: string;
40
+ /** i18n KEY for the view's name (never a literal: the switch is chrome). */
41
+ readonly labelKey: string;
42
+ /** The glyph beside the name. Omitted, the name stands alone. */
43
+ readonly icon?: ReactNode;
44
+ /**
45
+ * One of the pane's own arrangements. Ignored when {@link SearchView.render}
46
+ * is present, which replaces the arrangement entirely.
47
+ */
48
+ readonly layout?: SearchResultsLayout;
49
+ /**
50
+ * The whole result surface, for a view the pair cannot draw — a map, a
51
+ * comparison table. It receives the loaded rows; the pane keeps its four
52
+ * load arms around it, so "nothing found" and "we could not run this search"
53
+ * stay the pane's sentences rather than the slot's problem.
54
+ */
55
+ readonly render?: SearchResultsRenderer;
56
+ }
57
+
58
+ /** Rows — one wide row per result, the arrangement a scan reads fastest. */
59
+ function ListGlyph(): ReactElement {
60
+ return (
61
+ <ViewGlyph>
62
+ <path d="M4 6.5h16M4 12h16M4 17.5h16" />
63
+ </ViewGlyph>
64
+ );
65
+ }
66
+
67
+ /** Cards — as many columns as fit. */
68
+ function GridGlyph(): ReactElement {
69
+ return (
70
+ <ViewGlyph>
71
+ <rect x="4" y="4" width="7" height="7" rx="1.5" />
72
+ <rect x="13" y="4" width="7" height="7" rx="1.5" />
73
+ <rect x="4" y="13" width="7" height="7" rx="1.5" />
74
+ <rect x="13" y="13" width="7" height="7" rx="1.5" />
75
+ </ViewGlyph>
76
+ );
77
+ }
78
+
79
+ function ViewGlyph(props: { readonly children: ReactNode }): ReactElement {
80
+ return (
81
+ <svg
82
+ width="16"
83
+ height="16"
84
+ viewBox="0 0 24 24"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ strokeWidth="1.75"
88
+ strokeLinecap="round"
89
+ role="img"
90
+ aria-hidden="true"
91
+ >
92
+ {props.children}
93
+ </svg>
94
+ );
95
+ }
96
+
97
+ /** The two arrangements the pair itself can draw. */
98
+ export const SEARCH_BUILTIN_VIEWS: readonly SearchView[] = [
99
+ {
100
+ id: "list",
101
+ labelKey: SEARCH_I18N_KEYS.viewList,
102
+ icon: <ListGlyph />,
103
+ layout: "list",
104
+ },
105
+ {
106
+ id: "grid",
107
+ labelKey: SEARCH_I18N_KEYS.viewGrid,
108
+ icon: <GridGlyph />,
109
+ layout: "grid",
110
+ },
111
+ ];
112
+
113
+ /** The view `id` currently in force, or the first offered one. */
114
+ export function resolveView(
115
+ views: readonly SearchView[],
116
+ id: string | undefined
117
+ ): SearchView | undefined {
118
+ return views.find((view) => view.id === id) ?? views[0];
119
+ }
120
+
121
+ export interface ViewSwitchProps {
122
+ readonly views: readonly SearchView[];
123
+ readonly value: string;
124
+ readonly onChange: (id: string) => void;
125
+ }
126
+
127
+ /**
128
+ * The switch itself. Renders nothing for a single view: a control offering one
129
+ * choice is not a control, it is a label that can be clicked.
130
+ */
131
+ export function ViewSwitch(props: ViewSwitchProps): ReactElement | null {
132
+ const t = useT();
133
+ if (props.views.length < 2) return null;
134
+ return (
135
+ <Segmented<string>
136
+ aria-label={t(SEARCH_I18N_KEYS.viewLabel)}
137
+ value={props.value}
138
+ data-testid="search-view-switch"
139
+ onChange={props.onChange}
140
+ options={props.views.map((view) => ({
141
+ value: view.id,
142
+ label: t(view.labelKey),
143
+ ...(view.icon !== undefined ? { icon: view.icon } : {}),
144
+ }))}
145
+ />
146
+ );
147
+ }
@@ -21,7 +21,12 @@
21
21
  * owns walking the tree; this pair owns the `category` parameter.
22
22
  * 4. **`renderGeoFilter`** — the location control. `geo-react` owns the map
23
23
  * and the geocoder; this pair owns `lat`/`lon`/`radius_km`/`bbox`, and
24
- * keeps the controls that WIDEN a location a link already carries.
24
+ * keeps the controls that WIDEN a location a link already carries. Its two
25
+ * companions divide the same seam the same way: `geoLabel` is what the
26
+ * current place is CALLED (owning a coordinate is not permission to print
27
+ * one), and `defaultGeo` is where a fresh search opens when the URL names
28
+ * nowhere — the visitor's own position, resolved by whoever is allowed to
29
+ * ask for it.
25
30
  * 5. **retheming through the §68 token JSON** — every surface wraps itself
26
31
  * in the shared `SkinTheme`, so a host's regenerated `--stapel-*` custom
27
32
  * properties reach this skin with zero code.
@@ -43,6 +48,21 @@ export type {
43
48
  SearchResultsRenderer,
44
49
  } from "./SearchResultsPane.js";
45
50
 
51
+ export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
52
+ export type { FilterChipsProps } from "./FilterChips.js";
53
+
54
+ export {
55
+ FacetGroupControl,
56
+ facetGroupShape,
57
+ facetOptionNodes,
58
+ FACET_VISIBLE_OPTIONS,
59
+ } from "./FacetGroupControl.js";
60
+ export type {
61
+ FacetGroupControlProps,
62
+ FacetGroupShape,
63
+ FacetOptionNode,
64
+ } from "./FacetGroupControl.js";
65
+
46
66
  export { FacetPanelPane } from "./FacetPanelPane.js";
47
67
  export type {
48
68
  FacetPanelPaneProps,
@@ -58,6 +78,8 @@ export { SearchBox } from "./SearchBox.js";
58
78
  export type { SearchBoxProps } from "./SearchBox.js";
59
79
  export { SortSelect, SORT_SELECT_MIN_WIDTH } from "./SortSelect.js";
60
80
  export type { SortSelectProps } from "./SortSelect.js";
81
+ export { ViewSwitch, SEARCH_BUILTIN_VIEWS, resolveView } from "./ViewSwitch.js";
82
+ export type { ViewSwitchProps, SearchView, SearchResultsLayout } from "./ViewSwitch.js";
61
83
  export { PageSizeSelect, SEARCH_PAGE_SIZES } from "./PageSizeSelect.js";
62
84
  export type { PageSizeSelectProps } from "./PageSizeSelect.js";
63
85
  export { LanguageSelect } from "./LanguageSelect.js";