@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.
- package/CHANGELOG.md +61 -0
- package/dist/default/FacetGroupControl.d.ts +40 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -0
- package/dist/default/FacetGroupControl.js +176 -0
- package/dist/default/FacetGroupControl.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +30 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +28 -16
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +37 -0
- package/dist/default/FilterChips.d.ts.map +1 -0
- package/dist/default/FilterChips.js +203 -0
- package/dist/default/FilterChips.js.map +1 -0
- package/dist/default/SearchPage.d.ts +59 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +60 -10
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +19 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +15 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/ViewSwitch.d.ts +66 -0
- package/dist/default/ViewSwitch.d.ts.map +1 -0
- package/dist/default/ViewSwitch.js +49 -0
- package/dist/default/ViewSwitch.js.map +1 -0
- package/dist/default/index.d.ts +12 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +9 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +35 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +38 -3
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +32 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +42 -2
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -1
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +32 -3
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +312 -0
- package/src/default/FacetPanelPane.tsx +57 -63
- package/src/default/FilterChips.tsx +423 -0
- package/src/default/SearchPage.tsx +158 -16
- package/src/default/SearchResultsPane.tsx +39 -2
- package/src/default/ViewSwitch.tsx +147 -0
- package/src/default/index.ts +23 -1
- package/src/headless/SearchStateProvider.tsx +80 -3
- package/src/i18n/es.ts +10 -1
- package/src/i18n/keys.ts +44 -2
- 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
|
-
/**
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
{
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -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";
|