@stapel/search-react 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -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.map +1 -1
- package/dist/default/FacetPanelPane.js +3 -9
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +28 -0
- package/dist/default/FilterChips.d.ts.map +1 -0
- package/dist/default/FilterChips.js +204 -0
- package/dist/default/FilterChips.js.map +1 -0
- package/dist/default/SearchPage.d.ts +40 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +45 -9
- 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 +6 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -0
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +8 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +20 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +29 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +8 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +31 -2
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +312 -0
- package/src/default/FacetPanelPane.tsx +11 -57
- package/src/default/FilterChips.tsx +415 -0
- package/src/default/SearchPage.tsx +118 -15
- package/src/default/SearchResultsPane.tsx +39 -2
- package/src/default/ViewSwitch.tsx +147 -0
- package/src/default/index.ts +17 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +31 -0
- package/src/i18n/ru.ts +9 -0
|
@@ -66,10 +66,13 @@ import type {
|
|
|
66
66
|
CategoryFilterSlotProps,
|
|
67
67
|
GeoFilterSlotProps,
|
|
68
68
|
} from "./FacetPanelPane.js";
|
|
69
|
+
import { FilterChips } from "./FilterChips.js";
|
|
69
70
|
import { PageSizeSelect } from "./PageSizeSelect.js";
|
|
70
71
|
import { SearchBox } from "./SearchBox.js";
|
|
71
72
|
import { SearchResultsPane } from "./SearchResultsPane.js";
|
|
72
73
|
import { SortSelect } from "./SortSelect.js";
|
|
74
|
+
import { SEARCH_BUILTIN_VIEWS, ViewSwitch, resolveView } from "./ViewSwitch.js";
|
|
75
|
+
import type { SearchView } from "./ViewSwitch.js";
|
|
73
76
|
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
74
77
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
75
78
|
import { UrlIssueNotice } from "./UrlIssueNotice.js";
|
|
@@ -91,10 +94,31 @@ export type SearchFiltersLayout = "column" | "sheet";
|
|
|
91
94
|
*/
|
|
92
95
|
export const FILTERS_RAIL_WIDTH = 280;
|
|
93
96
|
|
|
94
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The rail: fixed width, never squeezed, never grown — and STICKY.
|
|
99
|
+
*
|
|
100
|
+
* A catalogue page is thirty cards long and the filters are at the top of it,
|
|
101
|
+
* so by the fourth row of results the controls that narrow the list are a
|
|
102
|
+
* screenful above the list they narrow: the only way to change a filter after
|
|
103
|
+
* scrolling was to scroll back. The rail now stays put while the results move
|
|
104
|
+
* under it, and scrolls INTERNALLY when its own content is taller than the
|
|
105
|
+
* window (`overflowY: auto` + a viewport-height cap), which is the one place a
|
|
106
|
+
* viewport measure is right — the sticky box's height IS the window's.
|
|
107
|
+
*
|
|
108
|
+
* `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
|
|
109
|
+
* height by default, and a stretched box has nothing to stick to.
|
|
110
|
+
*/
|
|
95
111
|
const RAIL: CSSProperties = {
|
|
96
112
|
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
97
113
|
maxWidth: FILTERS_RAIL_WIDTH,
|
|
114
|
+
position: "sticky",
|
|
115
|
+
top: 0,
|
|
116
|
+
alignSelf: "flex-start",
|
|
117
|
+
maxHeight: "100dvh",
|
|
118
|
+
overflowY: "auto",
|
|
119
|
+
overscrollBehavior: "contain",
|
|
120
|
+
// Room for the focus ring of the last control against the scroll edge.
|
|
121
|
+
paddingBlockEnd: spacing[2],
|
|
98
122
|
};
|
|
99
123
|
|
|
100
124
|
/** The results take what is left. `minWidth: 0` so a long word inside a card
|
|
@@ -152,6 +176,45 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
152
176
|
readonly defaultFiltersOpen?: boolean;
|
|
153
177
|
/** Offer a page-size control beside the sort. Default `true`. */
|
|
154
178
|
readonly pageSize?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* The trail above the heading — "Home / Cars / Sedans".
|
|
181
|
+
*
|
|
182
|
+
* A SLOT, because a breadcrumb is a walk up the CATEGORY tree and the tree
|
|
183
|
+
* belongs to `categories-react`; a search package knows the `category`
|
|
184
|
+
* parameter's value and nothing about its ancestors. Rendered above the
|
|
185
|
+
* results heading, inside the results column, so it sits over the list it
|
|
186
|
+
* describes rather than over the whole two-column page.
|
|
187
|
+
*/
|
|
188
|
+
readonly breadcrumb?: ReactNode;
|
|
189
|
+
/**
|
|
190
|
+
* The arrangements the view switch offers. Default: the pair's own list and
|
|
191
|
+
* grid. A deployment adds its own — `{ id: "map", labelKey, icon, render }`
|
|
192
|
+
* — and the switch treats it like the two that ship; see {@link SearchView}.
|
|
193
|
+
*
|
|
194
|
+
* A single view draws no switch at all.
|
|
195
|
+
*/
|
|
196
|
+
readonly views?: readonly SearchView[];
|
|
197
|
+
/** Which arrangement the page opens in. Default: the first of `views`. */
|
|
198
|
+
readonly defaultView?: string;
|
|
199
|
+
/** Told when the arrangement changes, for a host that remembers it. The
|
|
200
|
+
* view is NOT url state — see `<ViewSwitch>` for why. */
|
|
201
|
+
readonly onViewChange?: (id: string) => void;
|
|
202
|
+
/**
|
|
203
|
+
* The action at the trailing end of the results toolbar — conventionally
|
|
204
|
+
* "notify me about new ones".
|
|
205
|
+
*
|
|
206
|
+
* A SLOT, and it cannot be anything else: saving a search and mailing its
|
|
207
|
+
* new hits is a subscription with an owner, a schedule and a consent record,
|
|
208
|
+
* none of which this pair has. What the pair CAN state is where such a
|
|
209
|
+
* control belongs and that the page keeps room for it.
|
|
210
|
+
*/
|
|
211
|
+
readonly resultsAction?: ReactNode;
|
|
212
|
+
/**
|
|
213
|
+
* Heading level for the results caption. Default `1` — on a results SCREEN
|
|
214
|
+
* the list's name is the page's heading. A container that already prints its
|
|
215
|
+
* own `<h1>` above this page passes a lower level.
|
|
216
|
+
*/
|
|
217
|
+
readonly resultsHeadingLevel?: 1 | 2 | 3 | 4 | 5;
|
|
155
218
|
}
|
|
156
219
|
|
|
157
220
|
interface SearchPageBodyProps {
|
|
@@ -169,6 +232,12 @@ interface SearchPageBodyProps {
|
|
|
169
232
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
170
233
|
readonly defaultFiltersOpen?: boolean;
|
|
171
234
|
readonly pageSize?: boolean;
|
|
235
|
+
readonly breadcrumb?: ReactNode;
|
|
236
|
+
readonly views?: readonly SearchView[];
|
|
237
|
+
readonly defaultView?: string;
|
|
238
|
+
readonly onViewChange?: (id: string) => void;
|
|
239
|
+
readonly resultsAction?: ReactNode;
|
|
240
|
+
readonly resultsHeadingLevel?: 1 | 2 | 3 | 4 | 5;
|
|
172
241
|
}
|
|
173
242
|
|
|
174
243
|
/**
|
|
@@ -190,6 +259,18 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
190
259
|
props.filtersLayout ?? (surface === "sheet" ? "sheet" : "column");
|
|
191
260
|
const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
|
|
192
261
|
|
|
262
|
+
// How the results are ARRANGED. Component state, not URL state: it changes
|
|
263
|
+
// how the same answer is drawn, never what the answer is, so it must not
|
|
264
|
+
// rewrite the meaning of a link somebody shared (`<ViewSwitch>` §the view is
|
|
265
|
+
// not URL state).
|
|
266
|
+
const views = props.views ?? SEARCH_BUILTIN_VIEWS;
|
|
267
|
+
const [viewId, setViewId] = useState<string | undefined>(props.defaultView);
|
|
268
|
+
const view = resolveView(views, viewId) ?? { id: "", labelKey: "" };
|
|
269
|
+
const changeView = (next: string): void => {
|
|
270
|
+
setViewId(next);
|
|
271
|
+
props.onViewChange?.(next);
|
|
272
|
+
};
|
|
273
|
+
|
|
193
274
|
/**
|
|
194
275
|
* "Show 25 results", not "Show results".
|
|
195
276
|
*
|
|
@@ -256,16 +337,23 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
256
337
|
</Flex>
|
|
257
338
|
);
|
|
258
339
|
|
|
340
|
+
// The toolbar over the results: how they are ARRANGED, how they are ORDERED,
|
|
341
|
+
// how many per page — and the surface's own action at the trailing end.
|
|
259
342
|
const toolbar = (
|
|
260
343
|
<Flex align="center" wrap gap={spacing[3]}>
|
|
344
|
+
<ViewSwitch views={views} value={view.id} onChange={changeView} />
|
|
261
345
|
<SortSelect />
|
|
262
346
|
{props.pageSize !== false && <PageSizeSelect />}
|
|
347
|
+
{props.resultsAction}
|
|
263
348
|
</Flex>
|
|
264
349
|
);
|
|
265
350
|
|
|
266
351
|
const results = (
|
|
267
352
|
<SearchResultsPane
|
|
268
353
|
toolbar={toolbar}
|
|
354
|
+
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
355
|
+
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
356
|
+
{...(view.layout !== undefined ? { layout: view.layout } : {})}
|
|
269
357
|
{...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
|
|
270
358
|
{...(props.footer !== undefined ? { footer: props.footer } : {})}
|
|
271
359
|
{...(props.resultsHeading !== undefined
|
|
@@ -286,26 +374,29 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
286
374
|
data-filters-layout={layout}
|
|
287
375
|
>
|
|
288
376
|
{props.searchBox !== false && <SearchBox />}
|
|
377
|
+
{props.breadcrumb !== undefined && (
|
|
378
|
+
<div data-testid="search-breadcrumb">{props.breadcrumb}</div>
|
|
379
|
+
)}
|
|
289
380
|
<UrlIssueNotice />
|
|
290
381
|
|
|
291
382
|
{showFilters && layout === "sheet" ? (
|
|
292
383
|
<>
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
384
|
+
{/* The phone's filter row. It REPLACES the full-width "Filters (3)"
|
|
385
|
+
button that used to stand here: that button said how many
|
|
386
|
+
constraints were applied and not one word about WHICH, and put
|
|
387
|
+
every filter behind one tap onto a sheet you then had to scroll.
|
|
388
|
+
The chips state the filters on the page — and the leading chip is
|
|
389
|
+
still the whole panel, for the person who wants all of it. */}
|
|
390
|
+
<FilterChips
|
|
391
|
+
onOpenAll={() => {
|
|
299
392
|
setSheetOpen(true);
|
|
300
393
|
}}
|
|
301
|
-
|
|
302
|
-
{
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
: t(SEARCH_I18N_KEYS.filtersOpen, { count: facets.activeFilters })}
|
|
308
|
-
</Button>
|
|
394
|
+
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
395
|
+
{...(locale !== undefined ? { locale } : {})}
|
|
396
|
+
{...(props.renderGeoFilter !== undefined
|
|
397
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
398
|
+
: {})}
|
|
399
|
+
/>
|
|
309
400
|
<SkinDialog
|
|
310
401
|
open={sheetOpen}
|
|
311
402
|
onClose={() => {
|
|
@@ -368,6 +459,12 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
368
459
|
filtersLayout,
|
|
369
460
|
defaultFiltersOpen,
|
|
370
461
|
pageSize,
|
|
462
|
+
breadcrumb,
|
|
463
|
+
views,
|
|
464
|
+
defaultView,
|
|
465
|
+
onViewChange,
|
|
466
|
+
resultsAction,
|
|
467
|
+
resultsHeadingLevel,
|
|
371
468
|
mode,
|
|
372
469
|
...parseOptions
|
|
373
470
|
} = props;
|
|
@@ -390,6 +487,12 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
390
487
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
391
488
|
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
392
489
|
{...(pageSize !== undefined ? { pageSize } : {})}
|
|
490
|
+
{...(breadcrumb !== undefined ? { breadcrumb } : {})}
|
|
491
|
+
{...(views !== undefined ? { views } : {})}
|
|
492
|
+
{...(defaultView !== undefined ? { defaultView } : {})}
|
|
493
|
+
{...(onViewChange !== undefined ? { onViewChange } : {})}
|
|
494
|
+
{...(resultsAction !== undefined ? { resultsAction } : {})}
|
|
495
|
+
{...(resultsHeadingLevel !== undefined ? { resultsHeadingLevel } : {})}
|
|
393
496
|
/>
|
|
394
497
|
</SearchStateProvider>
|
|
395
498
|
</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
|
@@ -43,6 +43,21 @@ export type {
|
|
|
43
43
|
SearchResultsRenderer,
|
|
44
44
|
} from "./SearchResultsPane.js";
|
|
45
45
|
|
|
46
|
+
export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
|
|
47
|
+
export type { FilterChipsProps } from "./FilterChips.js";
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
FacetGroupControl,
|
|
51
|
+
facetGroupShape,
|
|
52
|
+
facetOptionNodes,
|
|
53
|
+
FACET_VISIBLE_OPTIONS,
|
|
54
|
+
} from "./FacetGroupControl.js";
|
|
55
|
+
export type {
|
|
56
|
+
FacetGroupControlProps,
|
|
57
|
+
FacetGroupShape,
|
|
58
|
+
FacetOptionNode,
|
|
59
|
+
} from "./FacetGroupControl.js";
|
|
60
|
+
|
|
46
61
|
export { FacetPanelPane } from "./FacetPanelPane.js";
|
|
47
62
|
export type {
|
|
48
63
|
FacetPanelPaneProps,
|
|
@@ -58,6 +73,8 @@ export { SearchBox } from "./SearchBox.js";
|
|
|
58
73
|
export type { SearchBoxProps } from "./SearchBox.js";
|
|
59
74
|
export { SortSelect, SORT_SELECT_MIN_WIDTH } from "./SortSelect.js";
|
|
60
75
|
export type { SortSelectProps } from "./SortSelect.js";
|
|
76
|
+
export { ViewSwitch, SEARCH_BUILTIN_VIEWS, resolveView } from "./ViewSwitch.js";
|
|
77
|
+
export type { ViewSwitchProps, SearchView, SearchResultsLayout } from "./ViewSwitch.js";
|
|
61
78
|
export { PageSizeSelect, SEARCH_PAGE_SIZES } from "./PageSizeSelect.js";
|
|
62
79
|
export type { PageSizeSelectProps } from "./PageSizeSelect.js";
|
|
63
80
|
export { LanguageSelect } from "./LanguageSelect.js";
|
package/src/i18n/es.ts
CHANGED
|
@@ -55,6 +55,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
55
55
|
"search.sort.distance": "Más cercanos",
|
|
56
56
|
"search.sort.server_chose": "Ordenado por {sort}",
|
|
57
57
|
|
|
58
|
+
"search.view.label": "Vista",
|
|
59
|
+
"search.view.list": "Lista",
|
|
60
|
+
"search.view.grid": "Cuadrícula",
|
|
61
|
+
|
|
58
62
|
"search.facets.title": "Filtros",
|
|
59
63
|
"search.facets.loading": "Cargando filtros…",
|
|
60
64
|
"search.facets.load_failed": "No pudimos cargar los filtros.",
|
|
@@ -74,6 +78,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
74
78
|
"search.facets.range_clear": "Quitar",
|
|
75
79
|
"search.facets.range_from_aria": "{feature}, desde",
|
|
76
80
|
"search.facets.range_to_aria": "{feature}, hasta",
|
|
81
|
+
"search.facets.show_all": "Ver todos ({count})",
|
|
82
|
+
"search.facets.show_less": "Ver menos",
|
|
77
83
|
"search.facets.range_invalid":
|
|
78
84
|
"«Desde» es mayor que «hasta», así que nada podría coincidir. Intercámbialos para aplicar el rango.",
|
|
79
85
|
|
|
@@ -84,6 +90,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
84
90
|
"search.filters.show_count_at_least.one": "Ver {count}+ resultado",
|
|
85
91
|
"search.filters.show_count_at_least.other": "Ver {count}+ resultados",
|
|
86
92
|
"search.filters.dismiss": "Cerrar los filtros",
|
|
93
|
+
"search.filters.chips_label": "Filtros",
|
|
94
|
+
"search.filters.all": "Todos los filtros",
|
|
95
|
+
"search.filters.chip_more": ", +{count}",
|
|
87
96
|
|
|
88
97
|
"search.category.title": "Categoría",
|
|
89
98
|
"search.category.clear": "Buscar en todo el catálogo",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -74,6 +74,13 @@ export const SEARCH_I18N_KEYS = {
|
|
|
74
74
|
sortDistance: "search.sort.distance",
|
|
75
75
|
sortServerChose: "search.sort.server_chose",
|
|
76
76
|
|
|
77
|
+
// ── how the results are ARRANGED (see ViewSwitch) ────────────────────────
|
|
78
|
+
/** The switch's own accessible name — a `Segmented` renders a radiogroup
|
|
79
|
+
* with no `<label for>` to name it. */
|
|
80
|
+
viewLabel: "search.view.label",
|
|
81
|
+
viewList: "search.view.list",
|
|
82
|
+
viewGrid: "search.view.grid",
|
|
83
|
+
|
|
77
84
|
// ── facets ───────────────────────────────────────────────────────────────
|
|
78
85
|
facetsTitle: "search.facets.title",
|
|
79
86
|
facetsLoading: "search.facets.loading",
|
|
@@ -96,6 +103,12 @@ export const SEARCH_I18N_KEYS = {
|
|
|
96
103
|
facetsRangeToAria: "search.facets.range_to_aria",
|
|
97
104
|
/** The reason "Apply" is off: the range reads backwards. */
|
|
98
105
|
facetsRangeInvalid: "search.facets.range_invalid",
|
|
106
|
+
/** The fold on a long facet group: "Show all (46)". The number is in
|
|
107
|
+
* brackets and not a counted noun, so this is one message and not a plural
|
|
108
|
+
* family — "(1)" never renders, because a group is only folded when the
|
|
109
|
+
* tail is longer than one row. */
|
|
110
|
+
facetsShowAll: "search.facets.show_all",
|
|
111
|
+
facetsShowLess: "search.facets.show_less",
|
|
99
112
|
|
|
100
113
|
// ── the filter panel as a whole (phone sheet + host slots) ───────────────
|
|
101
114
|
filtersOpen: "search.filters.open",
|
|
@@ -107,6 +120,15 @@ export const SEARCH_I18N_KEYS = {
|
|
|
107
120
|
/** "Show N+ results" — a FLOOR on the same button. A PLURAL FAMILY. */
|
|
108
121
|
filtersShowCountAtLeast: "search.filters.show_count_at_least",
|
|
109
122
|
filtersDismiss: "search.filters.dismiss",
|
|
123
|
+
/** The accessible name of the phone chip ROW — a `role="group"` holding a
|
|
124
|
+
* dozen buttons is announced as nothing at all without one. */
|
|
125
|
+
filtersChipsLabel: "search.filters.chips_label",
|
|
126
|
+
/** The leading, icon-only chip: the whole panel. Icon-only means the name
|
|
127
|
+
* exists ONLY here, so this key is the control's entire accessibility. */
|
|
128
|
+
filtersAll: "search.filters.all",
|
|
129
|
+
/** A chip filtering on more than one value: "Bosch, +2". Not a plural
|
|
130
|
+
* family — nothing is being counted in words. */
|
|
131
|
+
filtersChipMore: "search.filters.chip_more",
|
|
110
132
|
|
|
111
133
|
// ── category (a host slot, plus the control that removes it) ─────────────
|
|
112
134
|
categoryTitle: "search.category.title",
|
|
@@ -239,6 +261,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
239
261
|
"search.sort.distance": "Nearest first",
|
|
240
262
|
"search.sort.server_chose": "Sorted by {sort}",
|
|
241
263
|
|
|
264
|
+
"search.view.label": "View",
|
|
265
|
+
"search.view.list": "List",
|
|
266
|
+
"search.view.grid": "Grid",
|
|
267
|
+
|
|
242
268
|
"search.facets.title": "Filters",
|
|
243
269
|
"search.facets.loading": "Loading filters…",
|
|
244
270
|
"search.facets.load_failed": "We could not load the filters",
|
|
@@ -258,6 +284,8 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
258
284
|
"search.facets.range_clear": "Clear",
|
|
259
285
|
"search.facets.range_from_aria": "{feature}, from",
|
|
260
286
|
"search.facets.range_to_aria": "{feature}, up to",
|
|
287
|
+
"search.facets.show_all": "Show all ({count})",
|
|
288
|
+
"search.facets.show_less": "Show fewer",
|
|
261
289
|
"search.facets.range_invalid":
|
|
262
290
|
"“From” is larger than “to”, so nothing could match. Swap them to apply this range.",
|
|
263
291
|
|
|
@@ -268,6 +296,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
268
296
|
"search.filters.show_count_at_least.one": "Show {count}+ result",
|
|
269
297
|
"search.filters.show_count_at_least.other": "Show {count}+ results",
|
|
270
298
|
"search.filters.dismiss": "Close the filters",
|
|
299
|
+
"search.filters.chips_label": "Filters",
|
|
300
|
+
"search.filters.all": "All filters",
|
|
301
|
+
"search.filters.chip_more": ", +{count}",
|
|
271
302
|
|
|
272
303
|
"search.category.title": "Category",
|
|
273
304
|
"search.category.clear": "Search the whole catalogue",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -78,6 +78,10 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
78
78
|
"search.sort.distance": "Сначала ближние",
|
|
79
79
|
"search.sort.server_chose": "Отсортировано: {sort}",
|
|
80
80
|
|
|
81
|
+
"search.view.label": "Вид",
|
|
82
|
+
"search.view.list": "Списком",
|
|
83
|
+
"search.view.grid": "Плиткой",
|
|
84
|
+
|
|
81
85
|
"search.facets.title": "Фильтры",
|
|
82
86
|
"search.facets.loading": "Загружаем фильтры…",
|
|
83
87
|
"search.facets.load_failed": "Не удалось загрузить фильтры.",
|
|
@@ -97,6 +101,8 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
97
101
|
"search.facets.range_clear": "Сбросить",
|
|
98
102
|
"search.facets.range_from_aria": "{feature}, от",
|
|
99
103
|
"search.facets.range_to_aria": "{feature}, до",
|
|
104
|
+
"search.facets.show_all": "Показать все ({count})",
|
|
105
|
+
"search.facets.show_less": "Свернуть",
|
|
100
106
|
"search.facets.range_invalid":
|
|
101
107
|
"«От» больше, чем «до», — под такой диапазон ничего не подойдёт. Поменяйте значения местами.",
|
|
102
108
|
|
|
@@ -111,6 +117,9 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
111
117
|
"search.filters.show_count_at_least.many": "Показать {count}+ объявлений",
|
|
112
118
|
"search.filters.show_count_at_least.other": "Показать {count}+ объявления",
|
|
113
119
|
"search.filters.dismiss": "Закрыть фильтры",
|
|
120
|
+
"search.filters.chips_label": "Фильтры",
|
|
121
|
+
"search.filters.all": "Все фильтры",
|
|
122
|
+
"search.filters.chip_more": ", +{count}",
|
|
114
123
|
|
|
115
124
|
"search.category.title": "Категория",
|
|
116
125
|
"search.category.clear": "Искать по всему каталогу",
|