@stapel/search-react 0.20.0 → 0.22.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 +138 -0
- package/README.md +65 -2
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +68 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +34 -4
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +112 -7
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +3 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +55 -8
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +23 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +94 -0
- package/dist/default/PartitionChips.js.map +1 -0
- package/dist/default/PopularValues.d.ts +58 -0
- package/dist/default/PopularValues.d.ts.map +1 -0
- package/dist/default/PopularValues.js +46 -0
- package/dist/default/PopularValues.js.map +1 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +5 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +15 -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/headless/useFacetLabels.js +7 -7
- package/dist/headless/useFacetLabels.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +12 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +72 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +86 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +14 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +29 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +120 -16
- package/dist/state/facets.js.map +1 -1
- package/dist/state/translit.d.ts +58 -0
- package/dist/state/translit.d.ts.map +1 -0
- package/dist/state/translit.js +115 -0
- package/dist/state/translit.js.map +1 -0
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +43 -17
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +49 -2
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +77 -5
- package/src/default/FacetGroupControl.tsx +213 -8
- package/src/default/FacetPanelPane.tsx +36 -1
- package/src/default/LocationSummaryLine.tsx +114 -53
- package/src/default/PartitionChips.tsx +161 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/SearchPage.tsx +5 -0
- package/src/default/index.ts +15 -0
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +59 -3
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +16 -0
- package/src/i18n/keys.ts +91 -0
- package/src/i18n/ru.ts +20 -0
- package/src/index.ts +12 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +154 -28
- package/src/state/translit.ts +113 -0
- package/src/state/urlState.ts +72 -13
package/src/api/types.ts
CHANGED
|
@@ -13,17 +13,85 @@ import type { components } from "./generated/schema.js";
|
|
|
13
13
|
/** The generated schema table — the one source of truth for wire shapes. */
|
|
14
14
|
export type Schemas = components["schemas"];
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* One slug's captions: the GROUP's own name, and the words for its values.
|
|
18
|
+
*
|
|
19
|
+
* `label` is the group heading the server resolved from the category's
|
|
20
|
+
* feature definition, in the request's own language. It is the only source
|
|
21
|
+
* that always exists — `categoryFeatures` is an optional slot a live
|
|
22
|
+
* classified board never filled, and without it every heading in the panel
|
|
23
|
+
* was the raw index slug. `null` is the server saying it has no name for the
|
|
24
|
+
* slug either; ABSENT is a server too old to send one, and both read the same
|
|
25
|
+
* way here (fall through to the schema).
|
|
26
|
+
*
|
|
27
|
+
* WHAT THE GENERATOR LOST: the field is newer than this pair's pinned
|
|
28
|
+
* `schema.json`, so it is declared here as optional-and-nullable rather than
|
|
29
|
+
* regenerated into a shape a deployed older server does not send.
|
|
30
|
+
*/
|
|
31
|
+
export type FacetLabels = Schemas["FacetLabels"] & {
|
|
32
|
+
readonly label?: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** `facet_labels` as a whole: `{slug: {label, translatable, values}}`. */
|
|
36
|
+
export type FacetLabelsMap = Readonly<Record<string, FacetLabels>>;
|
|
37
|
+
|
|
38
|
+
/** `GET /query` 200 — the whole envelope. Corrected in two places: see
|
|
39
|
+
* {@link FacetMeta} and {@link FacetLabels}. */
|
|
40
|
+
export type SearchResponse = Omit<
|
|
41
|
+
Schemas["SearchResponse"],
|
|
42
|
+
"facet_meta" | "facet_labels"
|
|
43
|
+
> & {
|
|
44
|
+
readonly facet_meta: FacetMeta;
|
|
45
|
+
readonly facet_labels: FacetLabelsMap;
|
|
46
|
+
};
|
|
18
47
|
|
|
19
48
|
/** One result row. `promoted` is present on EVERY item under EVERY sort — a
|
|
20
49
|
* mandatory marking (DSA Art. 26), not an optional field, which is why the
|
|
21
50
|
* card slot's contract carries it and why the default skin renders it. */
|
|
22
51
|
export type SearchItem = Schemas["SearchItem"];
|
|
23
52
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
53
|
+
/**
|
|
54
|
+
* One group the counter COUNTED and then held back, because its buckets
|
|
55
|
+
* describe too little of the result set (`FACET_MIN_COVERAGE`).
|
|
56
|
+
*
|
|
57
|
+
* The existence of this list is what makes "this search offers no filters"
|
|
58
|
+
* a false sentence whenever it is not empty (D175).
|
|
59
|
+
*/
|
|
60
|
+
export interface FacetWithheldGroup {
|
|
61
|
+
readonly slug: string;
|
|
62
|
+
/** Sum of that group's bucket counts — how much of the set it describes. */
|
|
63
|
+
readonly coverage: number;
|
|
64
|
+
/** Size of the candidate set `coverage` is a fraction of. */
|
|
65
|
+
readonly candidates: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One category the candidate set is made of. */
|
|
69
|
+
export interface FacetCategoryCount {
|
|
70
|
+
/** The slash-joined id path (`"32/149/163"`) — the SAME string the
|
|
71
|
+
* `category` filter and `SearchQueryState.category` already take, so a
|
|
72
|
+
* panel can offer it as a filter without translating anything. */
|
|
73
|
+
readonly category: string;
|
|
74
|
+
readonly count: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The honesty block beside the counts: `approximate`, `candidates`,
|
|
79
|
+
* `counted`, `skipped`, and (stapel-search 0.12.0+) where the facet plan came
|
|
80
|
+
* from. Rendered, never swallowed (spec §4.2).
|
|
81
|
+
*
|
|
82
|
+
* WHAT THE GENERATOR LOST: drf-spectacular describes `withheld` and
|
|
83
|
+
* `categories` as bare `object` arrays, so the generated members are
|
|
84
|
+
* `{[key: string]: unknown}[]` — the two fields a panel has to read
|
|
85
|
+
* field-by-field are the two it cannot. Both are corrected here to the
|
|
86
|
+
* documented row shapes; nothing else about `FacetMeta` is hand-written.
|
|
87
|
+
*/
|
|
88
|
+
export type FacetMeta = Omit<
|
|
89
|
+
Schemas["FacetMeta"],
|
|
90
|
+
"withheld" | "categories"
|
|
91
|
+
> & {
|
|
92
|
+
readonly withheld: readonly FacetWithheldGroup[];
|
|
93
|
+
readonly categories: readonly FacetCategoryCount[];
|
|
94
|
+
};
|
|
27
95
|
|
|
28
96
|
/** `GET /suggest` 200, as the CURRENT generated schema describes it. */
|
|
29
97
|
export type SuggestResponse = Schemas["SuggestResponse"];
|
|
@@ -259,6 +327,10 @@ export type SearchDegradationKind =
|
|
|
259
327
|
| "exact_total"
|
|
260
328
|
| "exact_facet_counts"
|
|
261
329
|
| "category_rollup"
|
|
330
|
+
/** The engine has no `category_counts` verb, so the categories the result
|
|
331
|
+
* set is made of are unknown and no evidence facet plan could be drawn.
|
|
332
|
+
* An empty filter panel then means "we do not know", not "there are none". */
|
|
333
|
+
| "facet_plan_evidence"
|
|
262
334
|
| "scorer"
|
|
263
335
|
| "unknown";
|
|
264
336
|
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* 3. **A 60-option facet is not a list, it is a wall.** Every catalogue has
|
|
20
20
|
* one — brand, model, city — and printing all of it pushes every group
|
|
21
21
|
* under it off the screen.
|
|
22
|
+
* 4. **A vocabulary is not a long list, it is a DICTIONARY.** 418 car makes
|
|
23
|
+
* behind "Show all (418)" is a control whose only mode is "read all of
|
|
24
|
+
* it": the busiest values, a box, and the rest reachable by typing is the
|
|
25
|
+
* only shape that answers "I want a Toyota" in one gesture. See
|
|
26
|
+
* {@link isDictionaryFacet}.
|
|
22
27
|
*
|
|
23
28
|
* ── The presentation is DERIVED, never configured here ────────────────────
|
|
24
29
|
*
|
|
@@ -59,12 +64,17 @@
|
|
|
59
64
|
*/
|
|
60
65
|
import { useState } from "react";
|
|
61
66
|
import type { CSSProperties, ReactElement } from "react";
|
|
62
|
-
import { Button, Checkbox, Flex, Typography } from "antd";
|
|
67
|
+
import { Button, Checkbox, Flex, Input, Typography } from "antd";
|
|
63
68
|
import { useT } from "@stapel/core";
|
|
64
69
|
import { radii, spacing } from "@stapel/tokens";
|
|
65
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
VOCABULARY_BACKED_TYPES,
|
|
72
|
+
featureConfig,
|
|
73
|
+
featureType,
|
|
74
|
+
} from "@stapel/attributes-react";
|
|
66
75
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
67
76
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
77
|
+
import { translitPrefixMatch } from "../state/translit.js";
|
|
68
78
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
69
79
|
|
|
70
80
|
/**
|
|
@@ -77,8 +87,19 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
|
77
87
|
*/
|
|
78
88
|
export const FACET_VISIBLE_OPTIONS = 8;
|
|
79
89
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
90
|
+
/**
|
|
91
|
+
* From how many values a group stops being a list and becomes a DICTIONARY —
|
|
92
|
+
* the same eight, because it is the same fold: past it the group is drawn as
|
|
93
|
+
* its busiest values plus a box that searches the rest.
|
|
94
|
+
*/
|
|
95
|
+
export const FACET_DICTIONARY_THRESHOLD: number = FACET_VISIBLE_OPTIONS;
|
|
96
|
+
|
|
97
|
+
/** The four shapes a facet group takes. */
|
|
98
|
+
export type FacetGroupShape =
|
|
99
|
+
| "segmented"
|
|
100
|
+
| "nested"
|
|
101
|
+
| "checkbox"
|
|
102
|
+
| "dictionary";
|
|
82
103
|
|
|
83
104
|
/** The option rows of a group, already nested where the schema nests them. */
|
|
84
105
|
export interface FacetOptionNode {
|
|
@@ -126,17 +147,52 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
|
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
/**
|
|
129
|
-
*
|
|
150
|
+
* Is this group a DICTIONARY — a vocabulary's level, too long to scroll?
|
|
151
|
+
*
|
|
152
|
+
* Two ways to be one, because the schema is an optional slot and the live
|
|
153
|
+
* case is the one where it is empty:
|
|
154
|
+
*
|
|
155
|
+
* - the def types the slug `ref_select`/`ref_hierarchical_select`, i.e. its
|
|
156
|
+
* config is a POINTER into a vocabulary and there was never an option
|
|
157
|
+
* table to draw;
|
|
158
|
+
* - there is NO def at all and the answer came back with more values than a
|
|
159
|
+
* fold. At a live classified's cars branch the storefront passed an empty
|
|
160
|
+
* feature list, so the 418 makes arrived as an unnamed, untyped group of
|
|
161
|
+
* 418 checkboxes behind "Show all (418)". A box is the only control that
|
|
162
|
+
* answers that, and refusing to draw one because the schema is missing
|
|
163
|
+
* punishes the buyer for the wiring.
|
|
164
|
+
*
|
|
165
|
+
* Either way it takes more than {@link FACET_DICTIONARY_THRESHOLD} EVIDENCE
|
|
166
|
+
* buckets — values the answer actually counted. A zero-filled option table or
|
|
167
|
+
* a schema-only tail is a list a person can already read, and a box over it
|
|
168
|
+
* would search for values no document carries.
|
|
169
|
+
*/
|
|
170
|
+
export function isDictionaryFacet(group: FacetGroup): boolean {
|
|
171
|
+
const buckets = group.options.filter(
|
|
172
|
+
(option) => option.count !== null && option.count > 0
|
|
173
|
+
).length;
|
|
174
|
+
if (buckets <= FACET_DICTIONARY_THRESHOLD) return false;
|
|
175
|
+
const feature = group.feature;
|
|
176
|
+
if (feature === undefined) return true;
|
|
177
|
+
const type = featureType(feature);
|
|
178
|
+
return type !== undefined && VOCABULARY_BACKED_TYPES.includes(type);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Which of the four shapes a group takes.
|
|
130
183
|
*
|
|
131
184
|
* Order matters: a hierarchical facet is nested even when it is single-choice,
|
|
132
|
-
* because losing the tree costs more than losing the pills
|
|
185
|
+
* because losing the tree costs more than losing the pills; and a dictionary
|
|
186
|
+
* is a dictionary before it is a checkbox list, because the checkbox list is
|
|
187
|
+
* the shape it was drawn as when nobody could pick a make.
|
|
133
188
|
*/
|
|
134
189
|
export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
135
190
|
const feature = group.feature;
|
|
136
191
|
if (feature !== undefined && featureType(feature) === "hierarchical_select") {
|
|
137
192
|
return "nested";
|
|
138
193
|
}
|
|
139
|
-
|
|
194
|
+
if (singleChoice(feature)) return "segmented";
|
|
195
|
+
return isDictionaryFacet(group) ? "dictionary" : "checkbox";
|
|
140
196
|
}
|
|
141
197
|
|
|
142
198
|
/**
|
|
@@ -327,6 +383,142 @@ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
|
|
|
327
383
|
);
|
|
328
384
|
}
|
|
329
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The list a dictionary scrolls in. A vocabulary level is 418 makes: without
|
|
388
|
+
* a ceiling the group alone is longer than the rail, and the box that filters
|
|
389
|
+
* it scrolls off the top of the panel while you type into it.
|
|
390
|
+
*/
|
|
391
|
+
const DICTIONARY_LIST: CSSProperties = {
|
|
392
|
+
maxBlockSize: 320,
|
|
393
|
+
overflowY: "auto",
|
|
394
|
+
// The scroll must not clip a focus ring against the panel's edge.
|
|
395
|
+
paddingInlineEnd: spacing[1],
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A dictionary group: what is CHOSEN, a box, and the busiest values.
|
|
400
|
+
*
|
|
401
|
+
* ── Why the chosen values are their own block ─────────────────────────────
|
|
402
|
+
*
|
|
403
|
+
* They are the only rows that are not free to leave. A person who filtered by
|
|
404
|
+
* `Toyota` and then typed `bmw` must still be able to see — and undo — the
|
|
405
|
+
* filter that is narrowing what they are reading; a checkbox that scrolls out
|
|
406
|
+
* of the fold takes its own off-switch with it. So the chosen rows sit above
|
|
407
|
+
* the box, out of the filtered list entirely, and the list below never
|
|
408
|
+
* repeats them.
|
|
409
|
+
*
|
|
410
|
+
* ── The box filters LOCALLY, and matches across alphabets ─────────────────
|
|
411
|
+
*
|
|
412
|
+
* The buckets are already on the client — they arrived with the answer — so
|
|
413
|
+
* the box is a filter over an array and not a request per keystroke.
|
|
414
|
+
* `translitPrefixMatch` is what makes it usable in a catalogue whose values
|
|
415
|
+
* are Latin and whose buyers type Cyrillic: a Cyrillic "timberlend" finds
|
|
416
|
+
* `Timberland`.
|
|
417
|
+
* It is presentation, exactly like the panel's own search: nothing here
|
|
418
|
+
* touches the URL, because what a person typed to FIND a filter is not part
|
|
419
|
+
* of the search they would share.
|
|
420
|
+
*/
|
|
421
|
+
function DictionaryBody(props: {
|
|
422
|
+
readonly group: FacetGroup;
|
|
423
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
424
|
+
/** How many values before the box has to be used. */
|
|
425
|
+
readonly visible: number;
|
|
426
|
+
}): ReactElement {
|
|
427
|
+
const t = useT();
|
|
428
|
+
const { group } = props;
|
|
429
|
+
const [needle, setNeedle] = useState("");
|
|
430
|
+
const [expanded, setExpanded] = useState(false);
|
|
431
|
+
|
|
432
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
433
|
+
const rest = [...group.options.filter((option) => !option.selected)].sort(
|
|
434
|
+
(a, b) => (b.count ?? 0) - (a.count ?? 0)
|
|
435
|
+
);
|
|
436
|
+
const query = needle.trim();
|
|
437
|
+
const matched =
|
|
438
|
+
query === ""
|
|
439
|
+
? rest
|
|
440
|
+
: rest.filter(
|
|
441
|
+
(option) =>
|
|
442
|
+
translitPrefixMatch(query, option.label) ||
|
|
443
|
+
translitPrefixMatch(query, option.value)
|
|
444
|
+
);
|
|
445
|
+
// The fold only exists while nothing is typed: a query has already narrowed
|
|
446
|
+
// the list, and hiding its tail behind "Show all" would hide the answer.
|
|
447
|
+
const folded = query === "" && !expanded && matched.length > props.visible;
|
|
448
|
+
const shown = folded ? matched.slice(0, props.visible) : matched;
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<Flex vertical gap={spacing[1]} data-testid={`facet-dictionary-${group.slug}`}>
|
|
452
|
+
{chosen.length > 0 && (
|
|
453
|
+
<Flex
|
|
454
|
+
vertical
|
|
455
|
+
gap={spacing[1]}
|
|
456
|
+
data-testid={`facet-dictionary-chosen-${group.slug}`}
|
|
457
|
+
>
|
|
458
|
+
<Typography.Text type="secondary">
|
|
459
|
+
{t(SEARCH_I18N_KEYS.facetsDictionaryChosen)}
|
|
460
|
+
</Typography.Text>
|
|
461
|
+
{chosen.map((option) => (
|
|
462
|
+
<CheckboxRow
|
|
463
|
+
key={option.value}
|
|
464
|
+
group={group}
|
|
465
|
+
node={{ option, depth: 0 }}
|
|
466
|
+
onToggle={props.onToggle}
|
|
467
|
+
/>
|
|
468
|
+
))}
|
|
469
|
+
</Flex>
|
|
470
|
+
)}
|
|
471
|
+
<Input
|
|
472
|
+
allowClear
|
|
473
|
+
size="small"
|
|
474
|
+
value={needle}
|
|
475
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
476
|
+
aria-label={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
477
|
+
data-testid={`facet-dictionary-search-${group.slug}`}
|
|
478
|
+
onChange={(event) => {
|
|
479
|
+
setNeedle(event.target.value);
|
|
480
|
+
}}
|
|
481
|
+
/>
|
|
482
|
+
{shown.length === 0 ? (
|
|
483
|
+
<Typography.Text
|
|
484
|
+
type="secondary"
|
|
485
|
+
data-testid={`facet-dictionary-empty-${group.slug}`}
|
|
486
|
+
>
|
|
487
|
+
{t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
|
|
488
|
+
</Typography.Text>
|
|
489
|
+
) : (
|
|
490
|
+
<Flex vertical gap={spacing[1]} style={DICTIONARY_LIST}>
|
|
491
|
+
{shown.map((option) => (
|
|
492
|
+
<CheckboxRow
|
|
493
|
+
key={option.value}
|
|
494
|
+
group={group}
|
|
495
|
+
node={{ option, depth: 0 }}
|
|
496
|
+
onToggle={props.onToggle}
|
|
497
|
+
/>
|
|
498
|
+
))}
|
|
499
|
+
</Flex>
|
|
500
|
+
)}
|
|
501
|
+
{query === "" && matched.length > props.visible && (
|
|
502
|
+
<Button
|
|
503
|
+
type="link"
|
|
504
|
+
size="small"
|
|
505
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
506
|
+
data-testid={`facet-more-${group.slug}`}
|
|
507
|
+
data-analytics="none"
|
|
508
|
+
data-analytics-reason="expanding a filter group is a read, not a flow step"
|
|
509
|
+
onClick={() => {
|
|
510
|
+
setExpanded((was) => !was);
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
{expanded
|
|
514
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
515
|
+
: t(SEARCH_I18N_KEYS.facetsShowAll, { count: matched.length })}
|
|
516
|
+
</Button>
|
|
517
|
+
)}
|
|
518
|
+
</Flex>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
330
522
|
export interface FacetGroupControlProps {
|
|
331
523
|
readonly group: FacetGroup;
|
|
332
524
|
readonly onToggle: (slug: string, value: string) => void;
|
|
@@ -386,6 +578,11 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
386
578
|
data-testid={`facet-group-${group.slug}`}
|
|
387
579
|
data-counted={group.counted ? "true" : "false"}
|
|
388
580
|
data-shape={shape}
|
|
581
|
+
// Who named this heading — `none` means the raw slug is on screen
|
|
582
|
+
// because the answer sent no label and the schema defines none. It is
|
|
583
|
+
// drawn (a heading a person cannot read still beats none) and it is
|
|
584
|
+
// MARKED, so a storefront's own test can refuse to ship it.
|
|
585
|
+
data-label-source={group.labelSource}
|
|
389
586
|
>
|
|
390
587
|
{props.heading !== false &&
|
|
391
588
|
(disclosure ? (
|
|
@@ -421,7 +618,15 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
421
618
|
{/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
|
|
422
619
|
checkboxes are still a hundred stops for a screen reader, and the
|
|
423
620
|
measured rail held 118 of them. */}
|
|
424
|
-
{open && (
|
|
621
|
+
{open && shape === "dictionary" && (
|
|
622
|
+
<DictionaryBody
|
|
623
|
+
group={group}
|
|
624
|
+
onToggle={props.onToggle}
|
|
625
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
626
|
+
/>
|
|
627
|
+
)}
|
|
628
|
+
|
|
629
|
+
{open && shape !== "dictionary" && (
|
|
425
630
|
<>
|
|
426
631
|
{shape === "segmented" ? (
|
|
427
632
|
<Flex wrap gap={spacing[2]}>
|
|
@@ -91,6 +91,7 @@ import { featureName } from "@stapel/attributes-react";
|
|
|
91
91
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
92
92
|
import type { SearchGeo } from "../api/types.js";
|
|
93
93
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
94
|
+
import type { FacetPanelBag } from "../headless/FacetPanel.js";
|
|
94
95
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
95
96
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
96
97
|
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
@@ -421,6 +422,40 @@ function skippedNames(
|
|
|
421
422
|
.join(", ");
|
|
422
423
|
}
|
|
423
424
|
|
|
425
|
+
/**
|
|
426
|
+
* The empty arm of the panel — the ONE place "this search offers no filters"
|
|
427
|
+
* may be said, and the two answers that forbid it (D175).
|
|
428
|
+
*
|
|
429
|
+
* A group list of zero is not the same claim. `withheld` names groups the
|
|
430
|
+
* counter counted and then held back for describing too little of the result
|
|
431
|
+
* set: they exist, so the honest line is how many. `planUnavailable` means
|
|
432
|
+
* the server could not work a plan out at all — the reader hears that from
|
|
433
|
+
* `<DegradationNotice>`, and this arm's only job is to not contradict it.
|
|
434
|
+
*
|
|
435
|
+
* A COMPONENT rather than a ternary inline in `empty=`, because `LoadList`
|
|
436
|
+
* reads a nullish `empty` as "no arm given" and draws its own default, which
|
|
437
|
+
* is the sentence again. An element that renders `null` says nothing; a
|
|
438
|
+
* `null` prop says it louder.
|
|
439
|
+
*/
|
|
440
|
+
function FacetsEmptyArm(props: { readonly bag: FacetPanelBag }): ReactElement | null {
|
|
441
|
+
const t = useT();
|
|
442
|
+
const tPlural = useTPlural();
|
|
443
|
+
const withheld = props.bag.withheld.length;
|
|
444
|
+
if (withheld > 0) {
|
|
445
|
+
return (
|
|
446
|
+
<EmptyState
|
|
447
|
+
compact
|
|
448
|
+
title={tPlural(SEARCH_I18N_KEYS.facetsWithheld, { count: withheld })}
|
|
449
|
+
testId="facets-withheld"
|
|
450
|
+
/>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (props.bag.planUnavailable) return null;
|
|
454
|
+
return (
|
|
455
|
+
<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
424
459
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
425
460
|
const t = useT();
|
|
426
461
|
const { state } = useSearchState();
|
|
@@ -566,7 +601,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
566
601
|
state={bag.state}
|
|
567
602
|
testId="facets"
|
|
568
603
|
skeletonRows={4}
|
|
569
|
-
empty={<
|
|
604
|
+
empty={<FacetsEmptyArm bag={bag} />}
|
|
570
605
|
failed={(error) => (
|
|
571
606
|
<ErrorAlert
|
|
572
607
|
testId="facets-failed"
|
|
@@ -94,6 +94,9 @@ import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
|
|
|
94
94
|
|
|
95
95
|
/** The class the row carries, for {@link locationLineCss}. */
|
|
96
96
|
export const LOCATION_LINE_CLASS = "stapel-search-location-line";
|
|
97
|
+
/** The class the ONE location control carries — the place and the offer,
|
|
98
|
+
* inside a single box. See {@link locationLineCss} for what it fixes. */
|
|
99
|
+
export const LOCATION_LINE_GROUP_CLASS = "stapel-search-location-line-group";
|
|
97
100
|
/** The class the shrinking half carries. */
|
|
98
101
|
export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
|
|
99
102
|
/** The class the truncating label carries. */
|
|
@@ -117,6 +120,7 @@ export function locationLineCss(): string {
|
|
|
117
120
|
const where = `.${LOCATION_LINE_WHERE_CLASS}`;
|
|
118
121
|
const label = `.${LOCATION_LINE_LABEL_CLASS}`;
|
|
119
122
|
const end = `.${LOCATION_LINE_END_CLASS}`;
|
|
123
|
+
const group = `.${LOCATION_LINE_GROUP_CLASS}`;
|
|
120
124
|
return [
|
|
121
125
|
// `min-width:0` on the row too: a flex item's default `min-width:auto`
|
|
122
126
|
// is what makes a nested flex row refuse to shrink at all.
|
|
@@ -139,6 +143,35 @@ export function locationLineCss(): string {
|
|
|
139
143
|
`text-overflow:ellipsis;white-space:nowrap}`,
|
|
140
144
|
// The word a person is looking for never loses a pixel to a place name.
|
|
141
145
|
`${end}{flex:0 0 auto}`,
|
|
146
|
+
// ── ONE control, not two red links at opposite ends of the page ────────
|
|
147
|
+
//
|
|
148
|
+
// Measured on a live 1440px leaf: the row is 992px wide, "📍 Searching
|
|
149
|
+
// everywhere" sat at x=370 and "Near me · Within 25 km" at x=776, both
|
|
150
|
+
// drawn in the same brand red, with ~400px of air between them and
|
|
151
|
+
// nothing — no border, no ground, no heading — saying they were about the
|
|
152
|
+
// same thing. Two identical-looking links at opposite ends of a strip are
|
|
153
|
+
// read as two unrelated controls, which is what they looked like.
|
|
154
|
+
//
|
|
155
|
+
// So the place and the offer share ONE box with a border and a ground,
|
|
156
|
+
// sitting together at the leading edge, with a hairline between them: the
|
|
157
|
+
// eye gets a single object that says where this search is looking and
|
|
158
|
+
// offers the one move that would change it. The border comes from the
|
|
159
|
+
// role token, so it is right in both themes by construction.
|
|
160
|
+
// `min-inline-size:0` explicitly, and `flex-shrink` on the box rather
|
|
161
|
+
// than on its contents: a flex item's `min-width:auto` refuses to go
|
|
162
|
+
// below its own content, so without this the group pushed the filters
|
|
163
|
+
// door off the end of a 390px row instead of letting the place name give
|
|
164
|
+
// way — which is the half that can.
|
|
165
|
+
`${group}{display:flex;align-items:stretch;flex:0 1 auto;min-inline-size:0;` +
|
|
166
|
+
`border:1px solid ${cssVar("border")};border-radius:${String(radii.md)}px;` +
|
|
167
|
+
`background:${cssVar("surface-raised")};overflow:hidden}`,
|
|
168
|
+
// The two halves are separated by the box's own line, never by a gap: a
|
|
169
|
+
// gap inside a bordered group reads as two groups again.
|
|
170
|
+
`${group}>*+*{border-inline-start:1px solid ${cssVar("border")}}`,
|
|
171
|
+
`${group}>*{border-radius:0}`,
|
|
172
|
+
// Both halves pay for their own breathing room; the antd Button's inline
|
|
173
|
+
// padding is zeroed above so the box, not the button, sets the measure.
|
|
174
|
+
`${group}>*{padding-inline:${String(spacing[2])}px}`,
|
|
142
175
|
].join("");
|
|
143
176
|
}
|
|
144
177
|
|
|
@@ -149,7 +182,9 @@ const ROW: CSSProperties = { width: "100%" };
|
|
|
149
182
|
/** The shrinking half. The flex rules it needs live in the hoisted sheet
|
|
150
183
|
* (they have to reach antd's own wrapper span); this is the chrome. */
|
|
151
184
|
const LOCATION: CSSProperties = {
|
|
152
|
-
paddingInline:
|
|
185
|
+
// No `paddingInline` here: the group's sheet sets it for both halves, and
|
|
186
|
+
// an inline style would beat the sheet and leave one half flush against the
|
|
187
|
+
// border while the other breathed.
|
|
153
188
|
textAlign: "start",
|
|
154
189
|
};
|
|
155
190
|
|
|
@@ -159,7 +194,7 @@ const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
|
|
|
159
194
|
|
|
160
195
|
/** The offer. It never shrinks: it is three words and a number, and half of
|
|
161
196
|
* "Near me" is not an offer. */
|
|
162
|
-
const OFFER: CSSProperties = { flex: "0 0 auto"
|
|
197
|
+
const OFFER: CSSProperties = { flex: "0 0 auto" };
|
|
163
198
|
|
|
164
199
|
/**
|
|
165
200
|
* The count, IN the flow.
|
|
@@ -239,7 +274,8 @@ export function LocationSummaryLine(
|
|
|
239
274
|
props: LocationSummaryLineProps
|
|
240
275
|
): ReactElement {
|
|
241
276
|
const t = useT();
|
|
242
|
-
const { state, activeFilters, geoOffer, acceptGeoOffer } =
|
|
277
|
+
const { state, activeFilters, geoOffer, geoIsOffer, acceptGeoOffer } =
|
|
278
|
+
useSearchState();
|
|
243
279
|
const [open, setOpen] = useState(false);
|
|
244
280
|
const geo = state.geo;
|
|
245
281
|
|
|
@@ -257,13 +293,25 @@ export function LocationSummaryLine(
|
|
|
257
293
|
// the question), so this row never shows an offer beside a place.
|
|
258
294
|
const offerRadius =
|
|
259
295
|
geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
|
|
260
|
-
? t(SEARCH_I18N_KEYS.
|
|
296
|
+
? t(SEARCH_I18N_KEYS.geoRadiusKmShort, { km: geoOffer.radiusKm })
|
|
261
297
|
: undefined;
|
|
262
298
|
|
|
299
|
+
// What the line CALLS the place it is looking at.
|
|
300
|
+
//
|
|
301
|
+
// The host's name wins — it is the only one that can be checked by a reader
|
|
302
|
+
// — then the fact the provider holds, and only then the shape of the
|
|
303
|
+
// constraint. The middle arm is D184's second half: pressing "Near me"
|
|
304
|
+
// turned this line into "A chosen place on the map" for a person who had
|
|
305
|
+
// never opened a map. `geoIsOffer` is the provider reporting how the search
|
|
306
|
+
// came to be here, rather than this file inferring it from three numbers
|
|
307
|
+
// that look the same whatever produced them.
|
|
263
308
|
const where: ReactNode =
|
|
264
309
|
geo === undefined
|
|
265
310
|
? t(SEARCH_I18N_KEYS.geoEverywhere)
|
|
266
|
-
: (props.geoLabel ??
|
|
311
|
+
: (props.geoLabel ??
|
|
312
|
+
(geoIsOffer
|
|
313
|
+
? t(SEARCH_I18N_KEYS.geoNearYou)
|
|
314
|
+
: geoSummaryFallback(geo, t)));
|
|
267
315
|
|
|
268
316
|
return (
|
|
269
317
|
<>
|
|
@@ -279,60 +327,73 @@ export function LocationSummaryLine(
|
|
|
279
327
|
data-testid="search-location-summary"
|
|
280
328
|
data-geo={geo === undefined ? "off" : "on"}
|
|
281
329
|
>
|
|
282
|
-
{/*
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
style={LOCATION}
|
|
289
|
-
className={LOCATION_LINE_WHERE_CLASS}
|
|
290
|
-
data-testid="search-location-open"
|
|
291
|
-
data-analytics="none"
|
|
292
|
-
data-analytics-reason="opening the location sheet is a read, not a flow step"
|
|
293
|
-
onClick={() => {
|
|
294
|
-
setOpen(true);
|
|
295
|
-
}}
|
|
330
|
+
{/* ONE control: where this search is looking, and — while the
|
|
331
|
+
question is still open — the one move that would change it. See
|
|
332
|
+
`locationLineCss` for the measurement that made them one box. */}
|
|
333
|
+
<div
|
|
334
|
+
className={LOCATION_LINE_GROUP_CLASS}
|
|
335
|
+
data-testid="search-location-group"
|
|
296
336
|
>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
className={LOCATION_LINE_LABEL_CLASS}
|
|
302
|
-
data-testid="search-location-label"
|
|
303
|
-
>
|
|
304
|
-
{where}
|
|
305
|
-
{radius !== undefined && (
|
|
306
|
-
<span data-testid="search-location-radius">
|
|
307
|
-
{" · "}
|
|
308
|
-
{radius}
|
|
309
|
-
</span>
|
|
310
|
-
)}
|
|
311
|
-
</span>
|
|
312
|
-
</Button>
|
|
337
|
+
{/* The glyph is rendered as a CHILD rather than through antd's
|
|
338
|
+
`icon` prop: the icon slot sits outside the wrapper span, so the
|
|
339
|
+
label beside it could not be given a min-width of its own — and
|
|
340
|
+
a label that cannot shrink is a label that overflows.
|
|
313
341
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
{geoOffer !== undefined && (
|
|
342
|
+
`type="text"`, not `type="link"`: this half is a STATEMENT about
|
|
343
|
+
where the search is looking, and painting it the brand colour
|
|
344
|
+
made it indistinguishable from the offer beside it. The offer
|
|
345
|
+
keeps the colour, because the offer is the action. */}
|
|
319
346
|
<Button
|
|
320
|
-
type="
|
|
321
|
-
style={
|
|
322
|
-
|
|
347
|
+
type="text"
|
|
348
|
+
style={LOCATION}
|
|
349
|
+
className={LOCATION_LINE_WHERE_CLASS}
|
|
350
|
+
data-testid="search-location-open"
|
|
323
351
|
data-analytics="none"
|
|
324
|
-
data-analytics-reason="
|
|
325
|
-
onClick={
|
|
352
|
+
data-analytics-reason="opening the location sheet is a read, not a flow step"
|
|
353
|
+
onClick={() => {
|
|
354
|
+
setOpen(true);
|
|
355
|
+
}}
|
|
326
356
|
>
|
|
327
|
-
{
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
357
|
+
<span style={PIN}>
|
|
358
|
+
<PinGlyph />
|
|
359
|
+
</span>
|
|
360
|
+
<span
|
|
361
|
+
className={LOCATION_LINE_LABEL_CLASS}
|
|
362
|
+
data-testid="search-location-label"
|
|
363
|
+
>
|
|
364
|
+
{where}
|
|
365
|
+
{radius !== undefined && (
|
|
366
|
+
<span data-testid="search-location-radius">
|
|
367
|
+
{" · "}
|
|
368
|
+
{radius}
|
|
369
|
+
</span>
|
|
370
|
+
)}
|
|
371
|
+
</span>
|
|
334
372
|
</Button>
|
|
335
|
-
|
|
373
|
+
|
|
374
|
+
{/* The offer, and nothing is applied until it is pressed. Drawn only
|
|
375
|
+
when the host has a position to offer AND the search carries no
|
|
376
|
+
location of its own — the provider enforces the second half, so
|
|
377
|
+
this is one condition, not two that could disagree. */}
|
|
378
|
+
{geoOffer !== undefined && (
|
|
379
|
+
<Button
|
|
380
|
+
type="link"
|
|
381
|
+
style={OFFER}
|
|
382
|
+
data-testid="search-location-offer"
|
|
383
|
+
data-analytics="none"
|
|
384
|
+
data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
|
|
385
|
+
onClick={acceptGeoOffer}
|
|
386
|
+
>
|
|
387
|
+
{t(SEARCH_I18N_KEYS.geoNearMe)}
|
|
388
|
+
{offerRadius !== undefined && (
|
|
389
|
+
<span data-testid="search-location-offer-radius">
|
|
390
|
+
{" · "}
|
|
391
|
+
{offerRadius}
|
|
392
|
+
</span>
|
|
393
|
+
)}
|
|
394
|
+
</Button>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
336
397
|
|
|
337
398
|
{/* "Filters", not "All filters": this end of the row shares 390px
|
|
338
399
|
with a place name that can run to fifteen characters, and the word
|