@stapel/search-react 0.11.0 → 0.12.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 +120 -0
- package/MODULE.md +111 -0
- package/README.md +42 -0
- package/dist/api/generated/schema.d.ts +60 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/searchApi.d.ts +13 -6
- package/dist/api/searchApi.d.ts.map +1 -1
- package/dist/api/searchApi.js.map +1 -1
- package/dist/api/types.d.ts +107 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +29 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +13 -2
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +3 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +88 -3
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +161 -13
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +1 -1
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchBox.d.ts.map +1 -1
- package/dist/default/SearchBox.js +74 -4
- package/dist/default/SearchBox.js.map +1 -1
- package/dist/default/SearchPage.d.ts +29 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +25 -10
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +22 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +2 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +23 -0
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +29 -9
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +10 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +9 -1
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useFacetLabels.d.ts +39 -0
- package/dist/headless/useFacetLabels.d.ts.map +1 -0
- package/dist/headless/useFacetLabels.js +129 -0
- package/dist/headless/useFacetLabels.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +71 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -1
- package/dist/headless/useSearchBox.js +67 -2
- package/dist/headless/useSearchBox.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +7 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +54 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +6 -5
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +4 -3
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +7 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +3 -2
- package/dist/nav/manifest.js.map +1 -1
- package/dist/state/facets.d.ts +56 -15
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +101 -27
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +22 -2
- package/nav-manifest.json +4 -3
- package/package.json +11 -11
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +60 -6
- package/src/api/searchApi.ts +13 -6
- package/src/api/types.ts +115 -1
- package/src/default/FacetPanelPane.tsx +16 -2
- package/src/default/FilterChips.tsx +285 -38
- package/src/default/LocationSummaryLine.tsx +6 -1
- package/src/default/SearchBox.tsx +134 -6
- package/src/default/SearchPage.tsx +79 -5
- package/src/default/SearchResultsPane.tsx +48 -11
- package/src/default/SortSelect.tsx +70 -9
- package/src/default/index.ts +10 -2
- package/src/headless/FacetPanel.tsx +20 -1
- package/src/headless/useFacetLabels.ts +172 -0
- package/src/headless/useSearchBox.ts +132 -5
- package/src/i18n/es.ts +8 -0
- package/src/i18n/keys.ts +56 -0
- package/src/i18n/ru.ts +10 -0
- package/src/index.ts +21 -3
- package/src/model/queries.ts +6 -5
- package/src/model/queryKeys.ts +23 -0
- package/src/nav/manifest.ts +24 -2
- package/src/state/facets.ts +123 -39
package/src/api/types.ts
CHANGED
|
@@ -25,9 +25,123 @@ export type SearchItem = Schemas["SearchItem"];
|
|
|
25
25
|
* `counted`, `skipped`. Rendered, never swallowed (spec §4.2). */
|
|
26
26
|
export type FacetMeta = Schemas["FacetMeta"];
|
|
27
27
|
|
|
28
|
-
/** `GET /suggest` 200. */
|
|
28
|
+
/** `GET /suggest` 200, as the CURRENT generated schema describes it. */
|
|
29
29
|
export type SuggestResponse = Schemas["SuggestResponse"];
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* How a category's name matched the typed prefix. Informational — the server
|
|
33
|
+
* ranks by `count`, never by this.
|
|
34
|
+
*/
|
|
35
|
+
export type SuggestCategoryMatch = "prefix" | "substring";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One CATEGORY the type-ahead offers: a destination, not a search term.
|
|
39
|
+
*
|
|
40
|
+
* A classified's search box is a navigation control before it is a text
|
|
41
|
+
* filter. "Shorts" is not one destination but three — men's, women's,
|
|
42
|
+
* children's — and the only things that let a buyer pick between them are the
|
|
43
|
+
* ancestor path and how many live listings sit behind each. Both are here,
|
|
44
|
+
* and neither can be computed on the client: the count is one aggregate over
|
|
45
|
+
* the index, and a client-side matcher over a fetched tree would have the
|
|
46
|
+
* names and no numbers.
|
|
47
|
+
*/
|
|
48
|
+
export interface SuggestCategory {
|
|
49
|
+
readonly id: number;
|
|
50
|
+
readonly slug: string;
|
|
51
|
+
/** The category's own display name. */
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/**
|
|
54
|
+
* Display names root→leaf, e.g. `["Menswear", "Shorts"]` — this is what
|
|
55
|
+
* distinguishes three categories sharing a name, and it is what a row has
|
|
56
|
+
* to print.
|
|
57
|
+
*/
|
|
58
|
+
readonly path: readonly string[];
|
|
59
|
+
/**
|
|
60
|
+
* The ancestry as ids joined with `/`, ready to pass VERBATIM as the
|
|
61
|
+
* `category` parameter of `/query`.
|
|
62
|
+
*
|
|
63
|
+
* The server serves the joined string rather than only the segments
|
|
64
|
+
* precisely so that a client cannot invent a different join and silently
|
|
65
|
+
* miss — so nothing in this pair rebuilds it from {@link path} or
|
|
66
|
+
* {@link slug}.
|
|
67
|
+
*/
|
|
68
|
+
readonly category: string;
|
|
69
|
+
/**
|
|
70
|
+
* Live listings a buyer would see under this category, descendants
|
|
71
|
+
* included — the same number the SERP reports for it.
|
|
72
|
+
*/
|
|
73
|
+
readonly count: number;
|
|
74
|
+
/** Number of segments in {@link path}. */
|
|
75
|
+
readonly depth: number;
|
|
76
|
+
readonly match: SuggestCategoryMatch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `/suggest` answer this pair actually reads.
|
|
81
|
+
*
|
|
82
|
+
* GENERATOR NOTE, and the reason this is not `Schemas["SuggestResponse"]`:
|
|
83
|
+
* the generated type describes ONE server. stapel-search 0.7.0 made the answer
|
|
84
|
+
* three-part — `categories`, `terms`, and `items` as a deprecated alias of
|
|
85
|
+
* `terms` — plus `language` and `degraded`, and declares all five REQUIRED,
|
|
86
|
+
* which is true of a 0.7.0 answer and false of every answer sent by the
|
|
87
|
+
* servers a storefront is also deployed against.
|
|
88
|
+
*
|
|
89
|
+
* Every member but `backend` is therefore OPTIONAL here, which is not
|
|
90
|
+
* sloppiness but the deployment story: a storefront ships against whichever
|
|
91
|
+
* server is actually running, an older one sends no `categories` key at all,
|
|
92
|
+
* and "the key is absent" has to read as "this server offers no destinations"
|
|
93
|
+
* rather than as a crash or as an empty group under a heading. A pair typed
|
|
94
|
+
* against the required-field version would compile while reading `undefined`
|
|
95
|
+
* from a field the compiler swore was there.
|
|
96
|
+
*/
|
|
97
|
+
export interface SuggestAnswer {
|
|
98
|
+
readonly backend: string;
|
|
99
|
+
/** Destinations, ranked by live listing count desc, then depth, then name. */
|
|
100
|
+
readonly categories?: readonly SuggestCategory[];
|
|
101
|
+
/** Title prefixes from the index. */
|
|
102
|
+
readonly terms?: readonly string[];
|
|
103
|
+
/** Deprecated alias of {@link terms}, and the only half a pre-0.7.0 server
|
|
104
|
+
* sends. Read through {@link suggestTerms}, never directly. */
|
|
105
|
+
readonly items?: readonly string[];
|
|
106
|
+
/** Which dictionary answered — the same resolution `/query` reports. */
|
|
107
|
+
readonly language?: string;
|
|
108
|
+
/** What this answer could not do — see {@link SUGGEST_DEGRADED_CATEGORIES}
|
|
109
|
+
* and {@link SUGGEST_DEGRADED_ROLLUP}. */
|
|
110
|
+
readonly degraded?: readonly string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The suggest answer had NO category provider, so the categories half is
|
|
115
|
+
* empty for a reason that is not "nothing matched".
|
|
116
|
+
*
|
|
117
|
+
* A dropdown has no room for a sentence about a provider being down, and the
|
|
118
|
+
* person reading it is mid-word. So the group is ABSENT rather than empty —
|
|
119
|
+
* an empty group under a heading is the box claiming the catalogue has no
|
|
120
|
+
* section by that name, which is a different and untrue statement.
|
|
121
|
+
*/
|
|
122
|
+
export const SUGGEST_DEGRADED_CATEGORIES = "category_suggestions";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ancestry never arrived, so every stored path is one segment long and every
|
|
126
|
+
* count would read `0`.
|
|
127
|
+
*
|
|
128
|
+
* The rows are still destinations and still worth offering; their COUNTS are
|
|
129
|
+
* the part that is not an answer, so a surface drops the number rather than
|
|
130
|
+
* printing a catalogue of zeros.
|
|
131
|
+
*/
|
|
132
|
+
export const SUGGEST_DEGRADED_ROLLUP = "category_rollup";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The term half of a suggest answer, from whichever key this server sends.
|
|
136
|
+
*
|
|
137
|
+
* `terms` is 0.7.0's name and `items` is the deprecated alias kept for one
|
|
138
|
+
* minor; a client that read only one of them would go blank against half the
|
|
139
|
+
* servers in the fleet.
|
|
140
|
+
*/
|
|
141
|
+
export function suggestTerms(answer: SuggestAnswer | undefined): readonly string[] {
|
|
142
|
+
return answer?.terms ?? answer?.items ?? [];
|
|
143
|
+
}
|
|
144
|
+
|
|
31
145
|
/** `GET /ranking` 200 — the P2B Art. 5 disclosure. */
|
|
32
146
|
export type RankingResponse = Schemas["RankingResponse"];
|
|
33
147
|
|
|
@@ -54,6 +54,7 @@ import { featureName } from "@stapel/attributes-react";
|
|
|
54
54
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
55
55
|
import type { SearchGeo } from "../api/types.js";
|
|
56
56
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
57
|
+
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
57
58
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
58
59
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
59
60
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
@@ -78,11 +79,21 @@ export interface GeoFilterSlotProps {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
81
|
-
/** The category's feature schema — the source of option LABELS
|
|
82
|
-
* slugs get a numeric range row
|
|
82
|
+
/** The category's feature schema — the source of option LABELS, of which
|
|
83
|
+
* slugs get a numeric range row, and of which slugs are a filter at all
|
|
84
|
+
* (`isFacetableFeature`: an `imei` is counted and is not one). */
|
|
83
85
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
84
86
|
readonly locale?: string;
|
|
85
87
|
readonly enabled?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Name the values neither the answer nor the schema names — see
|
|
90
|
+
* {@link FacetLabelResolver}. A `ref_select` facet carries only a pointer to
|
|
91
|
+
* a vocabulary in its config, and the vocabulary is the host's to read.
|
|
92
|
+
*
|
|
93
|
+
* The same prop reaches `<FilterChips>` from `<SearchPage>`, so the panel
|
|
94
|
+
* and the chip row cannot print two different words for one value.
|
|
95
|
+
*/
|
|
96
|
+
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
86
97
|
/** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
|
|
87
98
|
* to a path). Unfilled, an active category still gets a "clear" control. */
|
|
88
99
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
@@ -315,6 +326,9 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
315
326
|
: {})}
|
|
316
327
|
{...(props.locale !== undefined ? { locale: props.locale } : {})}
|
|
317
328
|
{...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
|
|
329
|
+
{...(props.resolveFacetLabels !== undefined
|
|
330
|
+
? { resolveFacetLabels: props.resolveFacetLabels }
|
|
331
|
+
: {})}
|
|
318
332
|
>
|
|
319
333
|
{(bag) => {
|
|
320
334
|
// Built INSIDE the bag, because which axes exist is a property of
|
|
@@ -41,11 +41,69 @@
|
|
|
41
41
|
*
|
|
42
42
|
* ## The library is not inventing a classified's filters
|
|
43
43
|
*
|
|
44
|
-
* A chip exists for each facet group the SERVER returned
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
* A chip exists for each facet group the SERVER returned AND the category
|
|
45
|
+
* schema types as choosable (`isFacetableFeature` — an `imei` is counted and
|
|
46
|
+
* is not a filter), each numeric range the CATEGORY SCHEMA declares, the
|
|
47
|
+
* location, and the host's category control — plus the leading "all filters"
|
|
48
|
+
* chip. Nothing here knows what a "brand" or a "mileage" is; a deployment with
|
|
49
|
+
* three facets gets three chips and a deployment with none gets the leading
|
|
50
|
+
* chip alone.
|
|
51
|
+
*
|
|
52
|
+
* ## The ORDER of the row is the whole product
|
|
53
|
+
*
|
|
54
|
+
* At 390px a person sees roughly four chips before the fold, and everything
|
|
55
|
+
* past them costs a horizontal flick most people never make. On a live phone
|
|
56
|
+
* category the first seven were battery health, four parcel dimensions and two
|
|
57
|
+
* wholesale counts — every one of them a numeric ATTRIBUTE the category
|
|
58
|
+
* happens to declare, drawn before the condition, the vendor and the model
|
|
59
|
+
* anybody actually narrows by, and before the price.
|
|
60
|
+
*
|
|
61
|
+
* That is an ORDERING defect, not a facetability one, and it is fixed as one:
|
|
62
|
+
* nothing is deleted, because this package cannot tell a battery-health axis
|
|
63
|
+
* from a parcel-width axis and must not pretend to. Two categories declare
|
|
64
|
+
* `int` attributes and one of them is `mileage`. So the row states its order
|
|
65
|
+
* instead, out of evidence it actually has:
|
|
66
|
+
*
|
|
67
|
+
* 1. **the category chip** — narrowing the category decides which chips
|
|
68
|
+
* exist at all, the facet plan being derived from the leaf;
|
|
69
|
+
* 2. **the location chip** — the other host-slot filter;
|
|
70
|
+
* 3. **everything APPLIED**, in band order below. A constraint a person has
|
|
71
|
+
* set has to be reachable without a flick, or the row states filters that
|
|
72
|
+
* are on screen only if you go looking;
|
|
73
|
+
* 4. then everything unapplied, in band order:
|
|
74
|
+
* **core range axes** (`facet_meta.core_ranges` — the SERVER declaring an
|
|
75
|
+
* axis that exists for every document in every category: `price`), then
|
|
76
|
+
* **counted facet groups** (the server counted them for this search and
|
|
77
|
+
* each carries its remaining counts — the strongest evidence the row has
|
|
78
|
+
* that these are the axes this corpus is narrowed by), then
|
|
79
|
+
* **the category's numeric attributes**, which are form fields the
|
|
80
|
+
* composer collects and which no flag in the schema distinguishes from an
|
|
81
|
+
* axis a buyer uses.
|
|
82
|
+
*
|
|
83
|
+
* Band 3's attributes keep their controls, whole, in the panel behind the
|
|
84
|
+
* leading circle and at the tail of this row. `buildRangeGroups` is untouched:
|
|
85
|
+
* a rule that DELETED them would have to answer "on what evidence", and
|
|
86
|
+
* `facet_meta.skipped` — the one server signal that names a slug — means the
|
|
87
|
+
* counter ran out of plan slots at `MAX_FACET_FIELDS`, not that a person
|
|
88
|
+
* cannot filter by it. `r.<slug>` still answers for a skipped slug.
|
|
89
|
+
*
|
|
90
|
+
* ## Why the CATEGORY leads the row
|
|
91
|
+
*
|
|
92
|
+
* The owner's navigation model puts levels 1-2 of the catalogue on tiles and
|
|
93
|
+
* every level below them behind a cascading child selector, chosen "as a
|
|
94
|
+
* characteristic" — on the result list and in the composer alike. On the SERP
|
|
95
|
+
* that selector is a chip like any other, and it is the FIRST one, because
|
|
96
|
+
* narrowing the category is what changes which other chips exist at all: the
|
|
97
|
+
* facet plan is derived from the leaf category, so every chip to its right is
|
|
98
|
+
* downstream of it.
|
|
99
|
+
*
|
|
100
|
+
* The pair does not draw that selector. Walking the tree belongs to
|
|
101
|
+
* `categories-react`, so the chip is the host's `renderCategoryFilter` in the
|
|
102
|
+
* same sheet the other chips open, and a row whose host filled no such slot
|
|
103
|
+
* renders exactly as it did before. There is no synthesized category FACET
|
|
104
|
+
* anywhere here and there must not be one: the server counts no category
|
|
105
|
+
* buckets and the index has no read path for them, so any count this row drew
|
|
106
|
+
* beside a child category would be a number nobody could check.
|
|
49
107
|
*/
|
|
50
108
|
import { useState } from "react";
|
|
51
109
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
@@ -55,14 +113,19 @@ import { useT } from "@stapel/core";
|
|
|
55
113
|
import { radii, spacing } from "@stapel/tokens";
|
|
56
114
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
57
115
|
import { useFacetPanel } from "../headless/FacetPanel.js";
|
|
116
|
+
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
58
117
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
59
118
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
119
|
+
import type { RangeGroup } from "../state/ranges.js";
|
|
60
120
|
import type { FacetGroup } from "../state/facets.js";
|
|
61
121
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
62
122
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
63
123
|
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
64
124
|
import { geoSummaryFallback } from "./FacetPanelPane.js";
|
|
65
|
-
import type {
|
|
125
|
+
import type {
|
|
126
|
+
CategoryFilterSlotProps,
|
|
127
|
+
GeoFilterSlotProps,
|
|
128
|
+
} from "./FacetPanelPane.js";
|
|
66
129
|
import { CHIP_GEO_TEST_IDS, GeoSheet, useApplyLabel } from "./geoSheet.js";
|
|
67
130
|
|
|
68
131
|
/** The class the scroller carries, for {@link chipRowCss}. */
|
|
@@ -102,11 +165,99 @@ const CHIP: CSSProperties = { flex: "0 0 auto", borderRadius: radii.full };
|
|
|
102
165
|
/** Which picker is open, if any. `null` closes everything. */
|
|
103
166
|
type OpenChip = string | null;
|
|
104
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The three bands the row's non-slot chips fall into, most-evidenced first.
|
|
170
|
+
*
|
|
171
|
+
* `core_range` is the SERVER's own declaration (`facet_meta.core_ranges`) that
|
|
172
|
+
* an axis exists for every document in every category. `facet` is a group the
|
|
173
|
+
* server COUNTED for this search, which arrives with the remaining counts that
|
|
174
|
+
* make it a drill-down. `attribute_range` is a numeric field the category
|
|
175
|
+
* declares and nothing ranks — see this module's ordering note.
|
|
176
|
+
*/
|
|
177
|
+
export type ChipBand = "core_range" | "facet" | "attribute_range";
|
|
178
|
+
|
|
179
|
+
/** Band order, stated once. */
|
|
180
|
+
export const CHIP_BAND_ORDER: readonly ChipBand[] = [
|
|
181
|
+
"core_range",
|
|
182
|
+
"facet",
|
|
183
|
+
"attribute_range",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/** One non-slot chip, resolved to the band and the applied state it sorts by. */
|
|
187
|
+
export type ChipSpec =
|
|
188
|
+
| { readonly band: "core_range" | "attribute_range"; readonly range: RangeGroup }
|
|
189
|
+
| { readonly band: "facet"; readonly facet: FacetGroup };
|
|
190
|
+
|
|
191
|
+
/** Has the person set this filter? */
|
|
192
|
+
function specApplied(spec: ChipSpec): boolean {
|
|
193
|
+
return spec.band === "facet" ? spec.facet.selected.length > 0 : spec.range.active;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The row's order: applied first, then band, then the order each source
|
|
198
|
+
* already came in.
|
|
199
|
+
*
|
|
200
|
+
* Exported because the order IS the fix — the defect it closes is invisible to
|
|
201
|
+
* a test that only asks whether a chip exists, and a rule stated in prose next
|
|
202
|
+
* to an unexercised implementation is a rule that drifts.
|
|
203
|
+
*
|
|
204
|
+
* `Array.prototype.sort` is stable in every runtime this pair supports, so
|
|
205
|
+
* equal-ranked chips keep the order `buildRangeGroups` and `buildFacetGroups`
|
|
206
|
+
* gave them — a closed set's authored order survives all the way to the row.
|
|
207
|
+
*/
|
|
208
|
+
export function orderChipFilters(
|
|
209
|
+
ranges: readonly RangeGroup[],
|
|
210
|
+
facets: readonly FacetGroup[]
|
|
211
|
+
): readonly ChipSpec[] {
|
|
212
|
+
const specs: ChipSpec[] = [
|
|
213
|
+
...ranges.map(
|
|
214
|
+
(range): ChipSpec =>
|
|
215
|
+
range.core
|
|
216
|
+
? { band: "core_range", range }
|
|
217
|
+
: { band: "attribute_range", range }
|
|
218
|
+
),
|
|
219
|
+
...facets.map((facet): ChipSpec => ({ band: "facet", facet })),
|
|
220
|
+
];
|
|
221
|
+
return [...specs].sort((a, b) => {
|
|
222
|
+
const applied = Number(specApplied(b)) - Number(specApplied(a));
|
|
223
|
+
if (applied !== 0) return applied;
|
|
224
|
+
return CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
105
228
|
export interface FilterChipsProps {
|
|
106
229
|
/** The category's feature schema — the source of option labels, of which
|
|
107
|
-
* slugs get a range chip,
|
|
230
|
+
* slugs get a range chip, of which slugs are choosable at all, and of how
|
|
231
|
+
* each group is drawn. */
|
|
108
232
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
109
233
|
readonly locale?: string;
|
|
234
|
+
/**
|
|
235
|
+
* Name the values neither the answer nor the schema names — see
|
|
236
|
+
* {@link FacetLabelResolver}. The same prop reaches the filter panel, so a
|
|
237
|
+
* value cannot read one way on a chip and another way inside the sheet.
|
|
238
|
+
*/
|
|
239
|
+
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
240
|
+
/**
|
|
241
|
+
* The catalogue picker (`categories-react`), same slot the panel takes.
|
|
242
|
+
*
|
|
243
|
+
* Filled, it becomes the row's LEADING chip and opens in the same sheet as
|
|
244
|
+
* every other chip. Unfilled, the row draws no category chip at all — and
|
|
245
|
+
* that is not a constraint left without a control: the whole panel is one
|
|
246
|
+
* tap away behind the leading circle, and it carries the "search the whole
|
|
247
|
+
* catalogue" button for a link that arrived narrowed.
|
|
248
|
+
*/
|
|
249
|
+
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
250
|
+
/**
|
|
251
|
+
* What the current category is CALLED, in words.
|
|
252
|
+
*
|
|
253
|
+
* The pair holds a `root/leaf` PATH of slugs and nothing that turns one into
|
|
254
|
+
* a catalogue name — the tree belongs to `categories-react`, and whoever
|
|
255
|
+
* rendered the picker has the name already. Absent, the chip falls back to
|
|
256
|
+
* the path's last segment, which is the honest half-answer: it is what the
|
|
257
|
+
* search is actually narrowed to, it fits a 390px row where the whole path
|
|
258
|
+
* does not, and it is never invented.
|
|
259
|
+
*/
|
|
260
|
+
readonly categoryLabel?: ReactNode;
|
|
110
261
|
/** The location control (`geo-react`), same slot the panel takes. Without
|
|
111
262
|
* it the location chip appears only when the URL already carries a point,
|
|
112
263
|
* so a shared link can still be widened. */
|
|
@@ -123,6 +274,16 @@ export interface FilterChipsProps {
|
|
|
123
274
|
/** Open the whole panel — the leading chip's action. The page owns that
|
|
124
275
|
* sheet, because the page is the surface it covers. */
|
|
125
276
|
readonly onOpenAll: () => void;
|
|
277
|
+
/**
|
|
278
|
+
* Draw the location chip. Default `true`.
|
|
279
|
+
*
|
|
280
|
+
* `false` for a surface that already states the location ABOVE this row —
|
|
281
|
+
* `<LocationSummaryLine>` is that surface, and the phone SERP mounts both.
|
|
282
|
+
* Together they printed two location controls one line apart — the summary
|
|
283
|
+
* sentence over the chip's own prompt — opening two different sheets over
|
|
284
|
+
* one filter, which is the same constraint asked about twice.
|
|
285
|
+
*/
|
|
286
|
+
readonly geoChip?: boolean;
|
|
126
287
|
}
|
|
127
288
|
|
|
128
289
|
/**
|
|
@@ -140,14 +301,32 @@ function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknow
|
|
|
140
301
|
: `${first.label}${t(SEARCH_I18N_KEYS.filtersChipMore, { count: chosen.length - 1 })}`;
|
|
141
302
|
}
|
|
142
303
|
|
|
143
|
-
|
|
304
|
+
/**
|
|
305
|
+
* The last segment of a `root/leaf` category path.
|
|
306
|
+
*
|
|
307
|
+
* A chip has room for one word, and the whole path is what the panel prints on
|
|
308
|
+
* a surface with a column to spend. This is the pair's LAST resort — a host
|
|
309
|
+
* that renders the picker knows the catalogue's own name for the node and
|
|
310
|
+
* passes `categoryLabel` — but it is a real narrowing stated with a value the
|
|
311
|
+
* URL genuinely carries, which is the line the geo chip draws too: never print
|
|
312
|
+
* a coordinate, always print the name you actually have.
|
|
313
|
+
*/
|
|
314
|
+
export function categoryLeaf(path: string): string {
|
|
315
|
+
const parts = path.split("/").filter((part) => part.length > 0);
|
|
316
|
+
return parts[parts.length - 1] ?? path;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
144
320
|
const t = useT();
|
|
145
|
-
const { state } = useSearchState();
|
|
321
|
+
const { state, setCategory } = useSearchState();
|
|
146
322
|
const bag = useFacetPanel({
|
|
147
323
|
...(props.categoryFeatures !== undefined
|
|
148
324
|
? { categoryFeatures: props.categoryFeatures }
|
|
149
325
|
: {}),
|
|
150
326
|
...(props.locale !== undefined ? { locale: props.locale } : {}),
|
|
327
|
+
...(props.resolveFacetLabels !== undefined
|
|
328
|
+
? { resolveFacetLabels: props.resolveFacetLabels }
|
|
329
|
+
: {}),
|
|
151
330
|
});
|
|
152
331
|
const applyLabel = useApplyLabel();
|
|
153
332
|
const surface = useDialogSurface();
|
|
@@ -202,8 +381,25 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
|
202
381
|
</SkinDialog>
|
|
203
382
|
);
|
|
204
383
|
|
|
384
|
+
// The category chip exists only where a host can actually draw the picker:
|
|
385
|
+
// a chip that opened an empty sheet would be a filter affordance leading
|
|
386
|
+
// nowhere, and the whole panel behind the leading circle already carries the
|
|
387
|
+
// control that widens a narrowed link.
|
|
388
|
+
const showCategoryChip = props.renderCategoryFilter !== undefined;
|
|
389
|
+
const category = state.category;
|
|
390
|
+
// Same rule as every other chip: the CHOICE when there is one, the filter's
|
|
391
|
+
// own name when there is not.
|
|
392
|
+
const categoryChipLabel: ReactNode =
|
|
393
|
+
category === undefined
|
|
394
|
+
? t(SEARCH_I18N_KEYS.categoryTitle)
|
|
395
|
+
: (props.categoryLabel ?? categoryLeaf(category));
|
|
396
|
+
|
|
397
|
+
const ordered = orderChipFilters(ranges, groups);
|
|
398
|
+
|
|
205
399
|
const geo = state.geo;
|
|
206
|
-
const showGeoChip =
|
|
400
|
+
const showGeoChip =
|
|
401
|
+
props.geoChip !== false &&
|
|
402
|
+
(props.renderGeoFilter !== undefined || geo !== undefined);
|
|
207
403
|
// Nothing applied: the chip is the FILTER's name ("Location"), because there
|
|
208
404
|
// is no constraint to describe yet. Applied: the host's name for the place,
|
|
209
405
|
// and failing that the sentence that admits the pair does not know it.
|
|
@@ -212,6 +408,21 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
|
212
408
|
? t(SEARCH_I18N_KEYS.geoTitle)
|
|
213
409
|
: (props.geoLabel ?? geoSummaryFallback(geo, t));
|
|
214
410
|
|
|
411
|
+
/*
|
|
412
|
+
* A row of one button is not a chip row.
|
|
413
|
+
*
|
|
414
|
+
* The leading circle is the whole-panel door, and it is the only child this
|
|
415
|
+
* row is guaranteed. On a deployment whose plan has no facets for the
|
|
416
|
+
* current query — a free-text search with no category is exactly that: the
|
|
417
|
+
* plan comes from the CATEGORY's feature defs, so `facets` comes back `{}` —
|
|
418
|
+
* the row rendered as a lone circle floating between the location line and
|
|
419
|
+
* the results, a third filter affordance next to two working ones. When
|
|
420
|
+
* there is nothing to state, the row states nothing and the surface above
|
|
421
|
+
* keeps its own door.
|
|
422
|
+
*/
|
|
423
|
+
const hasChips = showCategoryChip || showGeoChip || ordered.length > 0;
|
|
424
|
+
if (!hasChips) return null;
|
|
425
|
+
|
|
215
426
|
return (
|
|
216
427
|
<>
|
|
217
428
|
<style href={CHIP_ROW_STYLE_HREF} precedence="default">
|
|
@@ -243,59 +454,95 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
|
243
454
|
{bag.activeFilters > 0 && <ActiveDot />}
|
|
244
455
|
</Button>
|
|
245
456
|
|
|
246
|
-
{
|
|
457
|
+
{/* FIRST, before every facet chip: narrowing the category is what
|
|
458
|
+
decides which facet chips exist at all. */}
|
|
459
|
+
{showCategoryChip && (
|
|
247
460
|
<Button
|
|
248
461
|
style={CHIP}
|
|
249
462
|
shape="round"
|
|
250
|
-
type={
|
|
251
|
-
data-testid="search-chip-
|
|
463
|
+
type={category !== undefined ? "primary" : "default"}
|
|
464
|
+
data-testid="search-chip-category"
|
|
252
465
|
data-analytics="none"
|
|
253
466
|
data-analytics-reason="a filter is a read, not a flow step"
|
|
254
467
|
onClick={() => {
|
|
255
|
-
setOpen("
|
|
468
|
+
setOpen("category");
|
|
256
469
|
}}
|
|
257
470
|
>
|
|
258
|
-
{
|
|
471
|
+
{categoryChipLabel}
|
|
259
472
|
</Button>
|
|
260
473
|
)}
|
|
261
474
|
|
|
262
|
-
{
|
|
475
|
+
{showGeoChip && (
|
|
263
476
|
<Button
|
|
264
|
-
key={group.slug}
|
|
265
477
|
style={CHIP}
|
|
266
478
|
shape="round"
|
|
267
|
-
type={
|
|
268
|
-
data-testid=
|
|
479
|
+
type={geo !== undefined ? "primary" : "default"}
|
|
480
|
+
data-testid="search-chip-geo"
|
|
269
481
|
data-analytics="none"
|
|
270
482
|
data-analytics-reason="a filter is a read, not a flow step"
|
|
271
483
|
onClick={() => {
|
|
272
|
-
setOpen(
|
|
484
|
+
setOpen("geo");
|
|
273
485
|
}}
|
|
274
486
|
>
|
|
275
|
-
{
|
|
487
|
+
{geoChipLabel}
|
|
276
488
|
</Button>
|
|
277
|
-
)
|
|
489
|
+
)}
|
|
278
490
|
|
|
279
|
-
{
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
491
|
+
{/* One list, in the row's stated order — see this module's ordering
|
|
492
|
+
note. Rendering ranges and facets as two separate `.map`s is what
|
|
493
|
+
put seven parcel dimensions in front of the price. */}
|
|
494
|
+
{ordered.map((spec) =>
|
|
495
|
+
spec.band === "facet" ? (
|
|
496
|
+
<Button
|
|
497
|
+
key={`facet:${spec.facet.slug}`}
|
|
498
|
+
style={CHIP}
|
|
499
|
+
shape="round"
|
|
500
|
+
type={spec.facet.selected.length > 0 ? "primary" : "default"}
|
|
501
|
+
data-testid={`search-chip-${spec.facet.slug}`}
|
|
502
|
+
data-band={spec.band}
|
|
503
|
+
data-analytics="none"
|
|
504
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
505
|
+
onClick={() => {
|
|
506
|
+
setOpen(`facet:${spec.facet.slug}`);
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
{chipLabel(spec.facet, t)}
|
|
510
|
+
</Button>
|
|
511
|
+
) : (
|
|
512
|
+
<Button
|
|
513
|
+
key={`range:${spec.range.slug}`}
|
|
514
|
+
style={CHIP}
|
|
515
|
+
shape="round"
|
|
516
|
+
type={spec.range.active ? "primary" : "default"}
|
|
517
|
+
data-testid={`search-chip-range-${spec.range.slug}`}
|
|
518
|
+
data-band={spec.band}
|
|
519
|
+
data-analytics="none"
|
|
520
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
521
|
+
onClick={() => {
|
|
522
|
+
setOpen(`range:${spec.range.slug}`);
|
|
523
|
+
}}
|
|
524
|
+
>
|
|
525
|
+
{spec.range.label}
|
|
526
|
+
</Button>
|
|
527
|
+
)
|
|
528
|
+
)}
|
|
295
529
|
</div>
|
|
296
530
|
|
|
297
531
|
{/* One sheet per chip, rendered only for the open one: a dozen mounted
|
|
298
532
|
dialogs is a dozen focus traps waiting for a stray `open`. */}
|
|
533
|
+
{open === "category" &&
|
|
534
|
+
props.renderCategoryFilter !== undefined &&
|
|
535
|
+
sheetFor(
|
|
536
|
+
"category",
|
|
537
|
+
t(SEARCH_I18N_KEYS.categoryTitle),
|
|
538
|
+
props.renderCategoryFilter({
|
|
539
|
+
value: category,
|
|
540
|
+
onChange: (path) => {
|
|
541
|
+
setCategory(path);
|
|
542
|
+
},
|
|
543
|
+
})
|
|
544
|
+
)}
|
|
545
|
+
|
|
299
546
|
{open?.startsWith("facet:") === true &&
|
|
300
547
|
(() => {
|
|
301
548
|
const slug = open.slice("facet:".length);
|
|
@@ -151,6 +151,11 @@ export function LocationSummaryLine(
|
|
|
151
151
|
)}
|
|
152
152
|
</Button>
|
|
153
153
|
|
|
154
|
+
{/* "Filters", not "All filters": this end of the row shares 390px
|
|
155
|
+
with a place name that can run to fifteen characters, and the word
|
|
156
|
+
the person is looking for is the noun. The panel's own heading
|
|
157
|
+
still says "All filters" — there it is naming a sheet, not a
|
|
158
|
+
door. */}
|
|
154
159
|
{/* The count, not a dot: this row has the width to say how many. */}
|
|
155
160
|
<Badge
|
|
156
161
|
count={activeFilters}
|
|
@@ -166,7 +171,7 @@ export function LocationSummaryLine(
|
|
|
166
171
|
data-analytics-reason="opening the filter sheet is a read, not a flow step"
|
|
167
172
|
onClick={props.onOpenAll}
|
|
168
173
|
>
|
|
169
|
-
{t(SEARCH_I18N_KEYS.
|
|
174
|
+
{t(SEARCH_I18N_KEYS.filtersShort)}
|
|
170
175
|
</Button>
|
|
171
176
|
</Badge>
|
|
172
177
|
</Flex>
|