@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
|
@@ -37,7 +37,6 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
37
37
|
import {
|
|
38
38
|
Alert,
|
|
39
39
|
Button,
|
|
40
|
-
Checkbox,
|
|
41
40
|
Divider,
|
|
42
41
|
Flex,
|
|
43
42
|
InputNumber,
|
|
@@ -56,7 +55,7 @@ import type { FeatureDef } from "@stapel/attributes-react";
|
|
|
56
55
|
import type { SearchGeo } from "../api/types.js";
|
|
57
56
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
58
57
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
59
|
-
import
|
|
58
|
+
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
60
59
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
61
60
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
62
61
|
import { LanguageSelect } from "./LanguageSelect.js";
|
|
@@ -90,6 +89,22 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
90
89
|
/** The location control (`geo-react`). Unfilled, a location that arrived in
|
|
91
90
|
* the URL still gets its radius and a "clear" control. */
|
|
92
91
|
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
92
|
+
/**
|
|
93
|
+
* What the current location constraint is CALLED, in words — the address or
|
|
94
|
+
* the city the host resolved.
|
|
95
|
+
*
|
|
96
|
+
* The panel owns `lat`/`lon`/`radius_km` as URL state and must never render
|
|
97
|
+
* them: a coordinate is what gets STORED, and storage is not a display
|
|
98
|
+
* concern. `55.756, 37.617` is unreadable to the one person who could have
|
|
99
|
+
* caught the mistake, so a wrong point looks as authoritative as a right one
|
|
100
|
+
* and a right one looks like machinery.
|
|
101
|
+
*
|
|
102
|
+
* Whoever turned an address into that point still has the address — the
|
|
103
|
+
* geocoder's own answer, the city an IP guess named, the label on the map
|
|
104
|
+
* pin — and hands it back here. Absent, the panel says a location is applied
|
|
105
|
+
* without pretending to name it (`search.geo.chosen_place`).
|
|
106
|
+
*/
|
|
107
|
+
readonly geoLabel?: ReactNode;
|
|
93
108
|
/** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
|
|
94
109
|
readonly languages?: readonly string[];
|
|
95
110
|
/**
|
|
@@ -102,45 +117,6 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
102
117
|
readonly heading?: ReactNode;
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
function OptionRow(props: {
|
|
106
|
-
group: FacetGroup;
|
|
107
|
-
option: FacetOption;
|
|
108
|
-
onToggle: (slug: string, value: string) => void;
|
|
109
|
-
}): ReactElement {
|
|
110
|
-
const t = useT();
|
|
111
|
-
const { option, group } = props;
|
|
112
|
-
return (
|
|
113
|
-
<Flex justify="space-between" align="center" gap={spacing[2]}>
|
|
114
|
-
<Checkbox
|
|
115
|
-
checked={option.selected}
|
|
116
|
-
data-testid={`facet-option-${group.slug}-${option.value}`}
|
|
117
|
-
data-analytics="none"
|
|
118
|
-
data-analytics-reason="a filter is a read, not a flow step"
|
|
119
|
-
onChange={() => {
|
|
120
|
-
props.onToggle(group.slug, option.value);
|
|
121
|
-
}}
|
|
122
|
-
>
|
|
123
|
-
{option.label}
|
|
124
|
-
</Checkbox>
|
|
125
|
-
{option.count === null ? (
|
|
126
|
-
<Typography.Text
|
|
127
|
-
type="secondary"
|
|
128
|
-
data-testid={`facet-count-${group.slug}-${option.value}`}
|
|
129
|
-
>
|
|
130
|
-
{t(SEARCH_I18N_KEYS.facetsNotCounted)}
|
|
131
|
-
</Typography.Text>
|
|
132
|
-
) : (
|
|
133
|
-
<Typography.Text
|
|
134
|
-
type="secondary"
|
|
135
|
-
data-testid={`facet-count-${group.slug}-${option.value}`}
|
|
136
|
-
>
|
|
137
|
-
{option.count}
|
|
138
|
-
</Typography.Text>
|
|
139
|
-
)}
|
|
140
|
-
</Flex>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
120
|
/** The category constraint: the host's control, or the door out of it. */
|
|
145
121
|
function CategoryFilter(props: {
|
|
146
122
|
render?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
@@ -188,6 +164,28 @@ function CategoryFilter(props: {
|
|
|
188
164
|
);
|
|
189
165
|
}
|
|
190
166
|
|
|
167
|
+
/**
|
|
168
|
+
* What a location constraint is called when the host handed down no name.
|
|
169
|
+
*
|
|
170
|
+
* Two sentences, and neither of them is a number. A box is describable without
|
|
171
|
+
* one — it is the area on the screen — and a point is not, so the point's
|
|
172
|
+
* sentence says a place was chosen and stops there. The alternative that was
|
|
173
|
+
* shipped for four releases printed `Around 55.756, 37.617`, which is the
|
|
174
|
+
* defect class this pair is otherwise careful about everywhere else: a value
|
|
175
|
+
* the reader cannot check, rendered as if it were an answer.
|
|
176
|
+
*
|
|
177
|
+
* Shared with `<FilterChips>` so the phone row and the desktop panel cannot
|
|
178
|
+
* drift into saying two different things about the same URL.
|
|
179
|
+
*/
|
|
180
|
+
export function geoSummaryFallback(
|
|
181
|
+
geo: SearchGeo,
|
|
182
|
+
t: (key: string) => string
|
|
183
|
+
): string {
|
|
184
|
+
return geo.kind === "bbox"
|
|
185
|
+
? t(SEARCH_I18N_KEYS.geoBox)
|
|
186
|
+
: t(SEARCH_I18N_KEYS.geoChosenPlace);
|
|
187
|
+
}
|
|
188
|
+
|
|
191
189
|
/**
|
|
192
190
|
* The location constraint.
|
|
193
191
|
*
|
|
@@ -196,9 +194,15 @@ function CategoryFilter(props: {
|
|
|
196
194
|
* number in the URL, and a link shared with `lat/lon/radius_km` is a link this
|
|
197
195
|
* panel can widen, tighten and clear without knowing what a map is. That is the
|
|
198
196
|
* difference between a slot and a hole.
|
|
197
|
+
*
|
|
198
|
+
* NAMING the centre is a third thing again, and it is the host's: this panel
|
|
199
|
+
* has two numbers and no way to turn them into a place. So it either says the
|
|
200
|
+
* name it was handed (`label`) or says that a place is chosen — never the
|
|
201
|
+
* numbers themselves. See {@link FacetPanelPaneProps.geoLabel}.
|
|
199
202
|
*/
|
|
200
203
|
function GeoFilter(props: {
|
|
201
204
|
render?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
205
|
+
label?: ReactNode;
|
|
202
206
|
}): ReactElement | null {
|
|
203
207
|
const t = useT();
|
|
204
208
|
const { state, setGeo } = useSearchState();
|
|
@@ -236,12 +240,7 @@ function GeoFilter(props: {
|
|
|
236
240
|
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
237
241
|
{slot}
|
|
238
242
|
<Typography.Text type="secondary" data-testid="search-geo-summary">
|
|
239
|
-
{
|
|
240
|
-
? t(SEARCH_I18N_KEYS.geoBox)
|
|
241
|
-
: t(SEARCH_I18N_KEYS.geoCenter, {
|
|
242
|
-
lat: geo.lat.toFixed(3),
|
|
243
|
-
lon: geo.lon.toFixed(3),
|
|
244
|
-
})}
|
|
243
|
+
{props.label ?? geoSummaryFallback(geo, t)}
|
|
245
244
|
</Typography.Text>
|
|
246
245
|
{geo.kind === "center" && (
|
|
247
246
|
<Flex gap={spacing[2]} align="center" wrap>
|
|
@@ -358,6 +357,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
358
357
|
{...(props.renderGeoFilter !== undefined
|
|
359
358
|
? { render: props.renderGeoFilter }
|
|
360
359
|
: {})}
|
|
360
|
+
{...(props.geoLabel !== undefined ? { label: props.geoLabel } : {})}
|
|
361
361
|
/>
|
|
362
362
|
|
|
363
363
|
{/* Honesty flags, not failures: the counts ARE approximate and
|
|
@@ -418,26 +418,20 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
418
418
|
the desktop panel, once as the range row and once as a
|
|
419
419
|
label over air. The skipped Alert above already names it;
|
|
420
420
|
a heading with no control under it names nothing. */}
|
|
421
|
+
{/* Each group draws itself the way its own schema says:
|
|
422
|
+
pills for a single-choice facet, indented children for a
|
|
423
|
+
hierarchical one, a fold for a long one. The panel does
|
|
424
|
+
not decide — `facetGroupShape` reads the same config keys
|
|
425
|
+
the attributes editor reads, so a facet cannot look one
|
|
426
|
+
way here and another way in the composer. */}
|
|
421
427
|
{groups
|
|
422
428
|
.filter((group) => group.options.length > 0)
|
|
423
429
|
.map((group) => (
|
|
424
|
-
<
|
|
425
|
-
vertical
|
|
426
|
-
gap={spacing[1]}
|
|
430
|
+
<FacetGroupControl
|
|
427
431
|
key={group.slug}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
<Typography.Text strong>{group.label}</Typography.Text>
|
|
432
|
-
{group.options.map((option) => (
|
|
433
|
-
<OptionRow
|
|
434
|
-
key={option.value}
|
|
435
|
-
group={group}
|
|
436
|
-
option={option}
|
|
437
|
-
onToggle={bag.toggle}
|
|
438
|
-
/>
|
|
439
|
-
))}
|
|
440
|
-
</Flex>
|
|
432
|
+
group={group}
|
|
433
|
+
onToggle={bag.toggle}
|
|
434
|
+
/>
|
|
441
435
|
))}
|
|
442
436
|
<Typography.Text type="secondary">
|
|
443
437
|
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FilterChips>` — the phone's filter row: one horizontally scrolling line of
|
|
3
|
+
* chips, each of which opens its OWN picker.
|
|
4
|
+
*
|
|
5
|
+
* ## What it replaces, and why the replacement is not cosmetic
|
|
6
|
+
*
|
|
7
|
+
* The phone filter path was a single full-width "Filters (3)" button that
|
|
8
|
+
* opened the whole panel. Everything about a search — the category, the price,
|
|
9
|
+
* the brand, where — was behind one tap onto a sheet you then had to scroll,
|
|
10
|
+
* and NOTHING about what was already applied was visible on the results page
|
|
11
|
+
* itself except a number in brackets. The chips row states the filters ON the
|
|
12
|
+
* page: the ones you have set read as set, the ones you have not are one tap
|
|
13
|
+
* from their own small picker, and the whole panel is still there behind the
|
|
14
|
+
* leading chip for the person who wants all of it at once.
|
|
15
|
+
*
|
|
16
|
+
* ## The row scrolls; the PAGE does not
|
|
17
|
+
*
|
|
18
|
+
* A row of chips wider than a 390px phone is the point — it is how the row
|
|
19
|
+
* holds eight filters. What must not happen is the page's own body growing a
|
|
20
|
+
* horizontal scrollbar, which is what a `flex-wrap: nowrap` row without an
|
|
21
|
+
* `overflow-x` owner does: the chips push the document wider, every other
|
|
22
|
+
* element on the page slides, and the visitor is left rubber-banding the whole
|
|
23
|
+
* screen sideways to read a price. So the row owns its overflow
|
|
24
|
+
* (`overflow-x: auto`), contains its overscroll so a flick past the last chip
|
|
25
|
+
* does not start scrolling the page behind it, and hides the scrollbar itself
|
|
26
|
+
* (a 15px grey trough under a 32px chip is chrome nobody asked for) — through
|
|
27
|
+
* a hoisted stylesheet, because `::-webkit-scrollbar` and `scrollbar-width`
|
|
28
|
+
* cannot be written as inline style.
|
|
29
|
+
*
|
|
30
|
+
* Hiding a scrollbar is only safe because nothing here is reachable ONLY by
|
|
31
|
+
* dragging it: every chip is a real `<button>`, so Tab walks the row and the
|
|
32
|
+
* browser scrolls the focused chip into view on its own.
|
|
33
|
+
*
|
|
34
|
+
* ## Every picker is a `SkinDialog`
|
|
35
|
+
*
|
|
36
|
+
* Which means every picker is a BOTTOM SHEET on a phone and a modal above it
|
|
37
|
+
* (owner ruling 2026-08-24, enforced by `stapel/no-bare-dialog`). The chips
|
|
38
|
+
* row is a phone surface, so in practice it is always the sheet — but the
|
|
39
|
+
* component does not decide that, the substrate does, and a tablet rendering
|
|
40
|
+
* the row gets the tablet answer without this file knowing.
|
|
41
|
+
*
|
|
42
|
+
* ## The library is not inventing a classified's filters
|
|
43
|
+
*
|
|
44
|
+
* A chip exists for each facet group the SERVER returned, each numeric range
|
|
45
|
+
* the CATEGORY SCHEMA declares, and the location — plus the leading
|
|
46
|
+
* "all filters" chip. Nothing here knows what a "brand" or a "mileage" is;
|
|
47
|
+
* a deployment with three facets gets three chips and a deployment with none
|
|
48
|
+
* gets the leading chip alone.
|
|
49
|
+
*/
|
|
50
|
+
import { useState } from "react";
|
|
51
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
52
|
+
import { Button, Flex, Typography } from "antd";
|
|
53
|
+
import { SkinDialog, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
54
|
+
import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
|
|
55
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
56
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
57
|
+
import { useFacetPanel } from "../headless/FacetPanel.js";
|
|
58
|
+
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
59
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
60
|
+
import { buildRangeGroups } from "../state/ranges.js";
|
|
61
|
+
import type { FacetGroup } from "../state/facets.js";
|
|
62
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
63
|
+
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
64
|
+
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
65
|
+
import { geoSummaryFallback } from "./FacetPanelPane.js";
|
|
66
|
+
import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
|
|
67
|
+
|
|
68
|
+
/** The class the scroller carries, for {@link chipRowCss}. */
|
|
69
|
+
export const CHIP_ROW_CLASS = "stapel-filter-chips";
|
|
70
|
+
|
|
71
|
+
/** The `href` the hoisted chip-row stylesheet is deduplicated by. */
|
|
72
|
+
export const CHIP_ROW_STYLE_HREF = "stapel-search-filter-chips";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The two rules an inline style cannot express: Firefox's `scrollbar-width`
|
|
76
|
+
* and WebKit's `::-webkit-scrollbar`. Static (no theme values), so one hoisted
|
|
77
|
+
* copy serves the document.
|
|
78
|
+
*/
|
|
79
|
+
export function chipRowCss(): string {
|
|
80
|
+
return [
|
|
81
|
+
`.${CHIP_ROW_CLASS}{scrollbar-width:none;-ms-overflow-style:none}`,
|
|
82
|
+
`.${CHIP_ROW_CLASS}::-webkit-scrollbar{display:none}`,
|
|
83
|
+
].join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The scroller: one line, its own overflow, nobody else's. */
|
|
87
|
+
const ROW: CSSProperties = {
|
|
88
|
+
display: "flex",
|
|
89
|
+
alignItems: "center",
|
|
90
|
+
gap: spacing[2],
|
|
91
|
+
flexWrap: "nowrap",
|
|
92
|
+
overflowX: "auto",
|
|
93
|
+
// A flick past the last chip must not hand the gesture to the page.
|
|
94
|
+
overscrollBehaviorInline: "contain",
|
|
95
|
+
// Room for the focus ring of the first and last chip, which a flush edge
|
|
96
|
+
// clips into invisibility.
|
|
97
|
+
paddingBlock: spacing[1],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const CHIP: CSSProperties = { flex: "0 0 auto", borderRadius: radii.full };
|
|
101
|
+
|
|
102
|
+
/** Which picker is open, if any. `null` closes everything. */
|
|
103
|
+
type OpenChip = string | null;
|
|
104
|
+
|
|
105
|
+
export interface FilterChipsProps {
|
|
106
|
+
/** The category's feature schema — the source of option labels, of which
|
|
107
|
+
* slugs get a range chip, and of how each group is drawn. */
|
|
108
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
109
|
+
readonly locale?: string;
|
|
110
|
+
/** The location control (`geo-react`), same slot the panel takes. Without
|
|
111
|
+
* it the location chip appears only when the URL already carries a point,
|
|
112
|
+
* so a shared link can still be widened. */
|
|
113
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
114
|
+
/**
|
|
115
|
+
* What the current location constraint is CALLED — see
|
|
116
|
+
* {@link FacetPanelPaneProps.geoLabel}. The chip carries it as its own text,
|
|
117
|
+
* which is the whole point of a chip row: "Berlin Mitte" states the filter
|
|
118
|
+
* on the results page, where `55.756, 37.617` stated only that a machine was
|
|
119
|
+
* involved. Absent, the chip reads `search.geo.chosen_place`; never a
|
|
120
|
+
* coordinate, on either surface.
|
|
121
|
+
*/
|
|
122
|
+
readonly geoLabel?: ReactNode;
|
|
123
|
+
/** Open the whole panel — the leading chip's action. The page owns that
|
|
124
|
+
* sheet, because the page is the surface it covers. */
|
|
125
|
+
readonly onOpenAll: () => void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The label a chip carries: the group's name alone when nothing is chosen,
|
|
130
|
+
* and the CHOICE when something is — "Brand" becomes "Bosch", "Brand, +2".
|
|
131
|
+
* A chip that reads "Brand" while filtering to Bosch is a lie the person can
|
|
132
|
+
* only catch by opening it.
|
|
133
|
+
*/
|
|
134
|
+
function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknown>) => string): string {
|
|
135
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
136
|
+
const first = chosen[0];
|
|
137
|
+
if (first === undefined) return group.label;
|
|
138
|
+
return chosen.length === 1
|
|
139
|
+
? first.label
|
|
140
|
+
: `${first.label}${t(SEARCH_I18N_KEYS.filtersChipMore, { count: chosen.length - 1 })}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
144
|
+
const t = useT();
|
|
145
|
+
const tPlural = useTPlural();
|
|
146
|
+
const { state, setGeo } = useSearchState();
|
|
147
|
+
const bag = useFacetPanel({
|
|
148
|
+
...(props.categoryFeatures !== undefined
|
|
149
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
150
|
+
: {}),
|
|
151
|
+
...(props.locale !== undefined ? { locale: props.locale } : {}),
|
|
152
|
+
});
|
|
153
|
+
const applied = useAppliedCount();
|
|
154
|
+
const surface = useDialogSurface();
|
|
155
|
+
const [open, setOpen] = useState<OpenChip>(null);
|
|
156
|
+
|
|
157
|
+
const ranges = buildRangeGroups({
|
|
158
|
+
state,
|
|
159
|
+
...(props.categoryFeatures !== undefined
|
|
160
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
161
|
+
: {}),
|
|
162
|
+
t,
|
|
163
|
+
});
|
|
164
|
+
const groups =
|
|
165
|
+
bag.state.status === "ready"
|
|
166
|
+
? bag.state.data.filter((group) => group.options.length > 0)
|
|
167
|
+
: [];
|
|
168
|
+
|
|
169
|
+
// The same sentence the panel's own commit button says, for the same reason:
|
|
170
|
+
// the results are BEHIND this sheet, so the button that closes it is the
|
|
171
|
+
// only place a person learns what their choice did.
|
|
172
|
+
const applyLabel =
|
|
173
|
+
applied.count === null || applied.kind === "unknown"
|
|
174
|
+
? t(SEARCH_I18N_KEYS.filtersApply)
|
|
175
|
+
: tPlural(
|
|
176
|
+
applied.kind === "at_least"
|
|
177
|
+
? SEARCH_I18N_KEYS.filtersShowCountAtLeast
|
|
178
|
+
: SEARCH_I18N_KEYS.filtersShowCount,
|
|
179
|
+
{ count: applied.count }
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const close = (): void => {
|
|
183
|
+
setOpen(null);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const sheetFor = (
|
|
187
|
+
id: string,
|
|
188
|
+
title: string,
|
|
189
|
+
body: ReactNode
|
|
190
|
+
): ReactElement => (
|
|
191
|
+
<SkinDialog
|
|
192
|
+
open={open === id}
|
|
193
|
+
onClose={close}
|
|
194
|
+
title={title}
|
|
195
|
+
dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
|
|
196
|
+
data-testid={`filter-chip-sheet-${id}`}
|
|
197
|
+
footer={
|
|
198
|
+
<Button
|
|
199
|
+
block
|
|
200
|
+
type="primary"
|
|
201
|
+
data-testid={`filter-chip-apply-${id}`}
|
|
202
|
+
data-analytics="none"
|
|
203
|
+
data-analytics-reason="the filter is already applied; this closes the sheet"
|
|
204
|
+
onClick={close}
|
|
205
|
+
>
|
|
206
|
+
{applyLabel}
|
|
207
|
+
</Button>
|
|
208
|
+
}
|
|
209
|
+
>
|
|
210
|
+
{body}
|
|
211
|
+
</SkinDialog>
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const geo = state.geo;
|
|
215
|
+
const showGeoChip = props.renderGeoFilter !== undefined || geo !== undefined;
|
|
216
|
+
// Nothing applied: the chip is the FILTER's name ("Location"), because there
|
|
217
|
+
// is no constraint to describe yet. Applied: the host's name for the place,
|
|
218
|
+
// and failing that the sentence that admits the pair does not know it.
|
|
219
|
+
const geoChipLabel: ReactNode =
|
|
220
|
+
geo === undefined
|
|
221
|
+
? t(SEARCH_I18N_KEYS.geoTitle)
|
|
222
|
+
: (props.geoLabel ?? geoSummaryFallback(geo, t));
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
<style href={CHIP_ROW_STYLE_HREF} precedence="default">
|
|
227
|
+
{chipRowCss()}
|
|
228
|
+
</style>
|
|
229
|
+
<div
|
|
230
|
+
className={CHIP_ROW_CLASS}
|
|
231
|
+
style={ROW}
|
|
232
|
+
role="group"
|
|
233
|
+
aria-label={t(SEARCH_I18N_KEYS.filtersChipsLabel)}
|
|
234
|
+
data-testid="search-filter-chips"
|
|
235
|
+
data-surface={surface}
|
|
236
|
+
>
|
|
237
|
+
{/* The leading chip: the whole panel, in one icon-sized target. It is
|
|
238
|
+
icon-only because it is the one chip whose meaning does not change
|
|
239
|
+
— and it carries a DOT, not a count, because the counts are already
|
|
240
|
+
written on the chips beside it. */}
|
|
241
|
+
<Button
|
|
242
|
+
shape="circle"
|
|
243
|
+
aria-label={t(SEARCH_I18N_KEYS.filtersAll)}
|
|
244
|
+
icon={<SlidersGlyph />}
|
|
245
|
+
style={CHIP}
|
|
246
|
+
data-testid="search-filters-open"
|
|
247
|
+
data-active={bag.activeFilters > 0 ? "true" : "false"}
|
|
248
|
+
data-analytics="none"
|
|
249
|
+
data-analytics-reason="opening the filter sheet is a read, not a flow step"
|
|
250
|
+
onClick={props.onOpenAll}
|
|
251
|
+
>
|
|
252
|
+
{bag.activeFilters > 0 && <ActiveDot />}
|
|
253
|
+
</Button>
|
|
254
|
+
|
|
255
|
+
{showGeoChip && (
|
|
256
|
+
<Button
|
|
257
|
+
style={CHIP}
|
|
258
|
+
shape="round"
|
|
259
|
+
type={geo !== undefined ? "primary" : "default"}
|
|
260
|
+
data-testid="search-chip-geo"
|
|
261
|
+
data-analytics="none"
|
|
262
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
263
|
+
onClick={() => {
|
|
264
|
+
setOpen("geo");
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{geoChipLabel}
|
|
268
|
+
</Button>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{ranges.map((group) => (
|
|
272
|
+
<Button
|
|
273
|
+
key={group.slug}
|
|
274
|
+
style={CHIP}
|
|
275
|
+
shape="round"
|
|
276
|
+
type={group.active ? "primary" : "default"}
|
|
277
|
+
data-testid={`search-chip-range-${group.slug}`}
|
|
278
|
+
data-analytics="none"
|
|
279
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
280
|
+
onClick={() => {
|
|
281
|
+
setOpen(`range:${group.slug}`);
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
{group.label}
|
|
285
|
+
</Button>
|
|
286
|
+
))}
|
|
287
|
+
|
|
288
|
+
{groups.map((group) => (
|
|
289
|
+
<Button
|
|
290
|
+
key={group.slug}
|
|
291
|
+
style={CHIP}
|
|
292
|
+
shape="round"
|
|
293
|
+
type={group.selected.length > 0 ? "primary" : "default"}
|
|
294
|
+
data-testid={`search-chip-${group.slug}`}
|
|
295
|
+
data-analytics="none"
|
|
296
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
297
|
+
onClick={() => {
|
|
298
|
+
setOpen(`facet:${group.slug}`);
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{chipLabel(group, t)}
|
|
302
|
+
</Button>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* One sheet per chip, rendered only for the open one: a dozen mounted
|
|
307
|
+
dialogs is a dozen focus traps waiting for a stray `open`. */}
|
|
308
|
+
{open?.startsWith("facet:") === true &&
|
|
309
|
+
(() => {
|
|
310
|
+
const slug = open.slice("facet:".length);
|
|
311
|
+
const group = groups.find((candidate) => candidate.slug === slug);
|
|
312
|
+
return group === undefined
|
|
313
|
+
? null
|
|
314
|
+
: sheetFor(
|
|
315
|
+
open,
|
|
316
|
+
group.label,
|
|
317
|
+
<FacetGroupControl
|
|
318
|
+
group={group}
|
|
319
|
+
onToggle={bag.toggle}
|
|
320
|
+
heading={false}
|
|
321
|
+
// A sheet devoted to ONE group has the room for all of it;
|
|
322
|
+
// "Show all" inside it would be a fold inside a fold.
|
|
323
|
+
visibleOptions={null}
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
326
|
+
})()}
|
|
327
|
+
|
|
328
|
+
{open?.startsWith("range:") === true &&
|
|
329
|
+
(() => {
|
|
330
|
+
const slug = open.slice("range:".length);
|
|
331
|
+
const group = ranges.find((candidate) => candidate.slug === slug);
|
|
332
|
+
return group === undefined
|
|
333
|
+
? null
|
|
334
|
+
: sheetFor(
|
|
335
|
+
open,
|
|
336
|
+
group.label,
|
|
337
|
+
<RangeFilterRow group={group} onApply={bag.setRange} />
|
|
338
|
+
);
|
|
339
|
+
})()}
|
|
340
|
+
|
|
341
|
+
{open === "geo" &&
|
|
342
|
+
sheetFor(
|
|
343
|
+
"geo",
|
|
344
|
+
t(SEARCH_I18N_KEYS.geoTitle),
|
|
345
|
+
<Flex vertical gap={spacing[3]}>
|
|
346
|
+
{/* SETTING a centre needs a geocoder, which is `geo-react`'s and
|
|
347
|
+
the deployment's. ADJUSTING or CLEARING one the URL already
|
|
348
|
+
carries does not — which is why the chip still opens on a
|
|
349
|
+
shared link with no slot filled, and why the absence of the
|
|
350
|
+
slot is NAMED in development rather than left as a gap under
|
|
351
|
+
the sheet's title. */}
|
|
352
|
+
{props.renderGeoFilter?.({
|
|
353
|
+
value: geo,
|
|
354
|
+
onChange: (next) => {
|
|
355
|
+
setGeo(next);
|
|
356
|
+
},
|
|
357
|
+
}) ?? <SlotPlaceholder name="renderGeoFilter" data-testid="search-chip-geo-slot" />}
|
|
358
|
+
{geo !== undefined && (
|
|
359
|
+
<>
|
|
360
|
+
<Typography.Text type="secondary" data-testid="search-chip-geo-summary">
|
|
361
|
+
{geoChipLabel}
|
|
362
|
+
</Typography.Text>
|
|
363
|
+
<Button
|
|
364
|
+
style={{ alignSelf: "flex-start" }}
|
|
365
|
+
data-testid="search-chip-geo-clear"
|
|
366
|
+
data-analytics="none"
|
|
367
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
368
|
+
onClick={() => {
|
|
369
|
+
setGeo(null);
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{t(SEARCH_I18N_KEYS.geoClear)}
|
|
373
|
+
</Button>
|
|
374
|
+
</>
|
|
375
|
+
)}
|
|
376
|
+
</Flex>
|
|
377
|
+
)}
|
|
378
|
+
</>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The mark on the "all filters" chip: something is applied. Not a count —
|
|
383
|
+
* the counts are on the chips beside it, and a number inside a 32px circle is
|
|
384
|
+
* a number nobody reads. */
|
|
385
|
+
function ActiveDot(): ReactElement {
|
|
386
|
+
return (
|
|
387
|
+
<span
|
|
388
|
+
aria-hidden="true"
|
|
389
|
+
data-testid="search-filters-dot"
|
|
390
|
+
style={{
|
|
391
|
+
position: "absolute",
|
|
392
|
+
insetInlineEnd: spacing[1],
|
|
393
|
+
insetBlockStart: spacing[1],
|
|
394
|
+
width: spacing[2],
|
|
395
|
+
height: spacing[2],
|
|
396
|
+
borderRadius: radii.full,
|
|
397
|
+
background: "currentColor",
|
|
398
|
+
}}
|
|
399
|
+
/>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Sliders — the one glyph a filter control is universally drawn with. Inline
|
|
404
|
+
* SVG, like every other glyph in the fleet's skins: no icon-font dependency. */
|
|
405
|
+
function SlidersGlyph(): ReactElement {
|
|
406
|
+
return (
|
|
407
|
+
<svg
|
|
408
|
+
width="18"
|
|
409
|
+
height="18"
|
|
410
|
+
viewBox="0 0 24 24"
|
|
411
|
+
fill="none"
|
|
412
|
+
stroke="currentColor"
|
|
413
|
+
strokeWidth="1.75"
|
|
414
|
+
strokeLinecap="round"
|
|
415
|
+
role="img"
|
|
416
|
+
aria-hidden="true"
|
|
417
|
+
>
|
|
418
|
+
<path d="M4 7h10M18 7h2M4 17h4M12 17h8" />
|
|
419
|
+
<circle cx="16" cy="7" r="2.2" />
|
|
420
|
+
<circle cx="10" cy="17" r="2.2" />
|
|
421
|
+
</svg>
|
|
422
|
+
);
|
|
423
|
+
}
|