@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<PartitionChips>` — the children of a `chips` category, as one row of
|
|
3
|
+
* single-select chips.
|
|
4
|
+
*
|
|
5
|
+
* A partition is a category whose children are not subcategories but one
|
|
6
|
+
* template split by a value their names express: buy / sell / let / rent,
|
|
7
|
+
* new / used, for boys / for girls. They keep their
|
|
8
|
+
* ids, their paths and their URLs — a listing still lands on a child — and
|
|
9
|
+
* only the PRESENTATION changes: the parent draws a feed, and the children
|
|
10
|
+
* are a choice above it rather than a grid of tiles the visitor has to pass
|
|
11
|
+
* through.
|
|
12
|
+
*
|
|
13
|
+
* Which categories are a partition is not decided here and not decided by
|
|
14
|
+
* this pair: `children_as` is a stored, derivable field on the category, and
|
|
15
|
+
* the storefront hands this component the children it resolved. What this
|
|
16
|
+
* component owns is that the choice is SINGLE-select and that "all" — the
|
|
17
|
+
* parent, unnarrowed — is one of the options rather than a way of clearing
|
|
18
|
+
* the others.
|
|
19
|
+
*
|
|
20
|
+
* ── Why a radiogroup and not a row of toggles ─────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Because exactly one of them is true at a time, and `aria-pressed` buttons
|
|
23
|
+
* say the opposite: they announce a set of independent switches, so a screen
|
|
24
|
+
* reader user hears no reason why pressing one released another. A radiogroup
|
|
25
|
+
* with roving tabindex is the pattern for "one of these": Tab reaches the row
|
|
26
|
+
* once and lands on the chosen chip, the arrow keys move along it, and the
|
|
27
|
+
* group's own name says what is being chosen.
|
|
28
|
+
*/
|
|
29
|
+
import { useRef } from "react";
|
|
30
|
+
import type {
|
|
31
|
+
CSSProperties,
|
|
32
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
33
|
+
ReactElement,
|
|
34
|
+
ReactNode,
|
|
35
|
+
} from "react";
|
|
36
|
+
import { Button } from "antd";
|
|
37
|
+
import { useT } from "@stapel/core";
|
|
38
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
39
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
40
|
+
|
|
41
|
+
/** One child of a partitioned category. `path` is the slash-joined id path
|
|
42
|
+
* the `category` parameter takes — the same string `SearchQueryState.category`
|
|
43
|
+
* carries, so a host never rebuilds it from ids. */
|
|
44
|
+
export interface PartitionChild {
|
|
45
|
+
readonly id: number | string;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PartitionChipsProps {
|
|
51
|
+
/** The children, in the order the catalogue declares them. */
|
|
52
|
+
readonly items: readonly PartitionChild[];
|
|
53
|
+
/** The chosen child's `path`, or `null` for the parent itself. CONTROLLED:
|
|
54
|
+
* this row keeps no state, because the choice is a `category` in the URL. */
|
|
55
|
+
readonly value: string | null;
|
|
56
|
+
readonly onChange: (path: string | null) => void;
|
|
57
|
+
/** The first chip's label. Defaults to `search.partition.all`. */
|
|
58
|
+
readonly allLabel?: ReactNode;
|
|
59
|
+
/** The row's accessible name. Defaults to `search.partition.label`. */
|
|
60
|
+
readonly label?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CHIP: CSSProperties = { borderRadius: radii.full };
|
|
64
|
+
|
|
65
|
+
const ROW: CSSProperties = {
|
|
66
|
+
display: "flex",
|
|
67
|
+
flexWrap: "wrap",
|
|
68
|
+
gap: spacing[2],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The row's cells, as `[value, label]` — the parent first, then the
|
|
72
|
+
* children in catalogue order. */
|
|
73
|
+
function cells(
|
|
74
|
+
items: readonly PartitionChild[],
|
|
75
|
+
allLabel: ReactNode
|
|
76
|
+
): readonly (readonly [string | null, ReactNode])[] {
|
|
77
|
+
return [
|
|
78
|
+
[null, allLabel] as const,
|
|
79
|
+
...items.map((item) => [item.path, item.name] as const),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
84
|
+
const t = useT();
|
|
85
|
+
const row = useRef<HTMLDivElement>(null);
|
|
86
|
+
const options = cells(
|
|
87
|
+
props.items,
|
|
88
|
+
props.allLabel ?? t(SEARCH_I18N_KEYS.partitionAll)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Arrow keys move the choice AND the focus, which is what a radiogroup
|
|
93
|
+
* does: in a single-select row the focused option is the selected one, so
|
|
94
|
+
* moving focus without choosing would leave the two disagreeing.
|
|
95
|
+
*/
|
|
96
|
+
const onKeyDown =
|
|
97
|
+
(index: number) =>
|
|
98
|
+
(event: ReactKeyboardEvent): void => {
|
|
99
|
+
const step =
|
|
100
|
+
event.key === "ArrowRight" || event.key === "ArrowDown"
|
|
101
|
+
? 1
|
|
102
|
+
: event.key === "ArrowLeft" || event.key === "ArrowUp"
|
|
103
|
+
? -1
|
|
104
|
+
: event.key === "Home"
|
|
105
|
+
? -index
|
|
106
|
+
: event.key === "End"
|
|
107
|
+
? options.length - 1 - index
|
|
108
|
+
: 0;
|
|
109
|
+
if (step === 0) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
const next = (index + step + options.length) % options.length;
|
|
112
|
+
const cell = options[next];
|
|
113
|
+
if (cell === undefined) return;
|
|
114
|
+
props.onChange(cell[0]);
|
|
115
|
+
const buttons = row.current?.querySelectorAll<HTMLElement>('[role="radio"]');
|
|
116
|
+
buttons?.[next]?.focus();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// A `value` naming no cell (a link into a child that has since moved) must
|
|
120
|
+
// still leave the row reachable by Tab, so the roving stop falls back to the
|
|
121
|
+
// parent chip rather than vanishing.
|
|
122
|
+
const active = options.findIndex(([value]) => value === props.value);
|
|
123
|
+
const stop = active >= 0 ? active : 0;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
style={ROW}
|
|
128
|
+
ref={row}
|
|
129
|
+
role="radiogroup"
|
|
130
|
+
aria-label={props.label ?? t(SEARCH_I18N_KEYS.partitionLabel)}
|
|
131
|
+
data-testid="partition-chips"
|
|
132
|
+
>
|
|
133
|
+
{options.map(([value, label], index) => {
|
|
134
|
+
const selected = value === props.value;
|
|
135
|
+
return (
|
|
136
|
+
<Button
|
|
137
|
+
key={value ?? "__all__"}
|
|
138
|
+
size="small"
|
|
139
|
+
shape="round"
|
|
140
|
+
type={selected ? "primary" : "default"}
|
|
141
|
+
role="radio"
|
|
142
|
+
aria-checked={selected}
|
|
143
|
+
// Roving tabindex: the row is ONE Tab stop and it lands on the
|
|
144
|
+
// chosen chip, not on the first of eight.
|
|
145
|
+
tabIndex={index === stop ? 0 : -1}
|
|
146
|
+
style={CHIP}
|
|
147
|
+
data-testid={`partition-chip-${value ?? "all"}`}
|
|
148
|
+
data-analytics="none"
|
|
149
|
+
data-analytics-reason="choosing a section is a read, not a flow step"
|
|
150
|
+
onKeyDown={onKeyDown(index)}
|
|
151
|
+
onClick={() => {
|
|
152
|
+
props.onChange(value);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{label}
|
|
156
|
+
</Button>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<PopularValues>` — the busiest values of one facet, printed as words a
|
|
3
|
+
* person can click, above the results.
|
|
4
|
+
*
|
|
5
|
+
* A dictionary facet in the rail answers "narrow this list"; it does not
|
|
6
|
+
* answer "what is IN this category". On a feed page of a cars leaf the second
|
|
7
|
+
* question is the one a visitor arrives with, and its answer is already in the
|
|
8
|
+
* envelope: `Toyota 802` is a bucket and its count. Printed as a multi-column
|
|
9
|
+
* block it is a table of contents for the category — the eleven makes that
|
|
10
|
+
* account for most of it, in one glance, each one a filter.
|
|
11
|
+
*
|
|
12
|
+
* ── What it is NOT ────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Not a replacement for the facet control: it shows the busy head of ONE
|
|
15
|
+
* group and says so with a link into the whole thing (`onShowAll`). Not a
|
|
16
|
+
* second source of counts either — the numbers are the answer's own
|
|
17
|
+
* drill-down counts, the same ones the checkbox rows carry, so a value cannot
|
|
18
|
+
* read `802` here and `93` in the panel.
|
|
19
|
+
*
|
|
20
|
+
* ── Hidden on a phone by a PROP ───────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* `hidden` rather than a media query inside, because whether a 390px screen
|
|
23
|
+
* has room for a block of forty links is a decision about the PAGE, and the
|
|
24
|
+
* page is the storefront's. A component that hid itself below some width of
|
|
25
|
+
* its own choosing would take that decision away from the only surface that
|
|
26
|
+
* knows what else is on screen — and would still render the DOM, which is
|
|
27
|
+
* what `display: none` costs a screen reader.
|
|
28
|
+
*/
|
|
29
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
30
|
+
import { Button, Flex, Typography } from "antd";
|
|
31
|
+
import { useT } from "@stapel/core";
|
|
32
|
+
import { spacing } from "@stapel/tokens";
|
|
33
|
+
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
34
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
35
|
+
|
|
36
|
+
/** How many values the block prints before the link into the full control. */
|
|
37
|
+
export const POPULAR_VALUES_LIMIT = 12;
|
|
38
|
+
|
|
39
|
+
/** How many columns the list flows into. Three fills a desktop content column
|
|
40
|
+
* without turning a make into a two-line wrap. */
|
|
41
|
+
export const POPULAR_VALUES_COLUMNS = 3;
|
|
42
|
+
|
|
43
|
+
/** A value with no evidence behind it is not a popular value. Uncounted
|
|
44
|
+
* options carry `count: null` and are dropped here rather than printed with a
|
|
45
|
+
* blank where the number belongs — the block IS the numbers. */
|
|
46
|
+
function hasEvidence(option: FacetOption): boolean {
|
|
47
|
+
return option.count !== null && option.count > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The busiest values of the group, count-descending, capped. */
|
|
51
|
+
export function popularOptions(
|
|
52
|
+
group: FacetGroup,
|
|
53
|
+
limit: number = POPULAR_VALUES_LIMIT
|
|
54
|
+
): readonly FacetOption[] {
|
|
55
|
+
return [...group.options.filter(hasEvidence)]
|
|
56
|
+
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
|
|
57
|
+
.slice(0, limit);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ROW: CSSProperties = {
|
|
61
|
+
// `break-inside` keeps a value and its count on one line when the browser
|
|
62
|
+
// decides where the column ends.
|
|
63
|
+
breakInside: "avoid",
|
|
64
|
+
display: "flex",
|
|
65
|
+
gap: spacing[2],
|
|
66
|
+
alignItems: "baseline",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface PopularValuesProps {
|
|
70
|
+
/** The group to print — normally the first `ref_select` of the plan. */
|
|
71
|
+
readonly group: FacetGroup;
|
|
72
|
+
/** Apply one value. Same signature as the panel's `toggle`, so a host can
|
|
73
|
+
* hand the facet bag's own function straight in. */
|
|
74
|
+
readonly onApply: (slug: string, value: string) => void;
|
|
75
|
+
/** Draw nothing. The phone, decided by the page — see the module note. */
|
|
76
|
+
readonly hidden?: boolean;
|
|
77
|
+
/** How many values. Default {@link POPULAR_VALUES_LIMIT}. */
|
|
78
|
+
readonly limit?: number;
|
|
79
|
+
/** How many columns. Default {@link POPULAR_VALUES_COLUMNS}. */
|
|
80
|
+
readonly columns?: number;
|
|
81
|
+
/** The block's heading. Defaults to the group's own label; `null` draws
|
|
82
|
+
* none, for a surface that has already named the axis. */
|
|
83
|
+
readonly heading?: ReactNode;
|
|
84
|
+
/** Open the full control. Absent draws no link — a link that goes nowhere
|
|
85
|
+
* is worse than a block that stops. */
|
|
86
|
+
readonly onShowAll?: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
90
|
+
const t = useT();
|
|
91
|
+
const { group } = props;
|
|
92
|
+
if (props.hidden === true) return null;
|
|
93
|
+
const options = popularOptions(group, props.limit ?? POPULAR_VALUES_LIMIT);
|
|
94
|
+
if (options.length === 0) return null;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Flex
|
|
98
|
+
vertical
|
|
99
|
+
gap={spacing[2]}
|
|
100
|
+
data-testid={`popular-values-${group.slug}`}
|
|
101
|
+
data-label-source={group.labelSource}
|
|
102
|
+
>
|
|
103
|
+
{props.heading !== null && (
|
|
104
|
+
<Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
|
|
105
|
+
)}
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
columnCount: props.columns ?? POPULAR_VALUES_COLUMNS,
|
|
109
|
+
columnGap: spacing[4],
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{options.map((option) => (
|
|
113
|
+
<div key={option.value} style={ROW}>
|
|
114
|
+
<Button
|
|
115
|
+
type="link"
|
|
116
|
+
size="small"
|
|
117
|
+
style={{ paddingInline: 0, height: "auto" }}
|
|
118
|
+
data-testid={`popular-value-${group.slug}-${option.value}`}
|
|
119
|
+
data-analytics="none"
|
|
120
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
121
|
+
onClick={() => {
|
|
122
|
+
props.onApply(group.slug, option.value);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{option.label}
|
|
126
|
+
</Button>
|
|
127
|
+
<Typography.Text
|
|
128
|
+
type="secondary"
|
|
129
|
+
data-testid={`popular-count-${group.slug}-${option.value}`}
|
|
130
|
+
>
|
|
131
|
+
{option.count}
|
|
132
|
+
</Typography.Text>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
{props.onShowAll !== undefined && (
|
|
137
|
+
<Button
|
|
138
|
+
type="link"
|
|
139
|
+
size="small"
|
|
140
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
141
|
+
data-testid={`popular-all-${group.slug}`}
|
|
142
|
+
data-analytics="none"
|
|
143
|
+
data-analytics-reason="opening a filter control is a read, not a flow step"
|
|
144
|
+
onClick={props.onShowAll}
|
|
145
|
+
>
|
|
146
|
+
{t(SEARCH_I18N_KEYS.facetsPopularAll)}
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
149
|
+
</Flex>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -426,6 +426,11 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
426
426
|
const filtersEmpty =
|
|
427
427
|
facets.state.status === "ready" &&
|
|
428
428
|
facets.state.data.length === 0 &&
|
|
429
|
+
// Zero groups is not zero filters: `withheld` names groups the server
|
|
430
|
+
// counted and held back for covering too little of the result set, and
|
|
431
|
+
// the panel's job is to say how many (D175). Skipping the column would
|
|
432
|
+
// put that sentence nowhere.
|
|
433
|
+
facets.withheld.length === 0 &&
|
|
429
434
|
facets.activeFilters === 0 &&
|
|
430
435
|
ranges.length === 0 &&
|
|
431
436
|
state.category === undefined &&
|
package/src/default/index.ts
CHANGED
|
@@ -72,6 +72,8 @@ export {
|
|
|
72
72
|
FacetGroupControl,
|
|
73
73
|
facetGroupShape,
|
|
74
74
|
facetOptionNodes,
|
|
75
|
+
isDictionaryFacet,
|
|
76
|
+
FACET_DICTIONARY_THRESHOLD,
|
|
75
77
|
FACET_VISIBLE_OPTIONS,
|
|
76
78
|
} from "./FacetGroupControl.js";
|
|
77
79
|
export type {
|
|
@@ -80,6 +82,19 @@ export type {
|
|
|
80
82
|
FacetOptionNode,
|
|
81
83
|
} from "./FacetGroupControl.js";
|
|
82
84
|
|
|
85
|
+
// ── the browse surfaces a storefront PLACES (this pair does not lay them
|
|
86
|
+
// out: where a popular-values block or a partition row belongs on a
|
|
87
|
+
// category page is the page's decision) ──────────────────────────────────
|
|
88
|
+
export {
|
|
89
|
+
PopularValues,
|
|
90
|
+
popularOptions,
|
|
91
|
+
POPULAR_VALUES_COLUMNS,
|
|
92
|
+
POPULAR_VALUES_LIMIT,
|
|
93
|
+
} from "./PopularValues.js";
|
|
94
|
+
export type { PopularValuesProps } from "./PopularValues.js";
|
|
95
|
+
export { PartitionChips } from "./PartitionChips.js";
|
|
96
|
+
export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
|
|
97
|
+
|
|
83
98
|
export { FacetPanelPane } from "./FacetPanelPane.js";
|
|
84
99
|
export type {
|
|
85
100
|
FacetPanelPaneProps,
|
|
@@ -2,9 +2,15 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { loadStateFromQuery, mapLoad, useT } from "@stapel/core";
|
|
3
3
|
import type { LoadState } from "@stapel/core";
|
|
4
4
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
FacetCategoryCount,
|
|
7
|
+
FacetMeta,
|
|
8
|
+
FacetWithheldGroup,
|
|
9
|
+
SearchRange,
|
|
10
|
+
} from "../api/types.js";
|
|
6
11
|
import { useSearchQuery } from "../model/queries.js";
|
|
7
12
|
import { buildFacetGroups } from "../state/facets.js";
|
|
13
|
+
import { FACET_PLAN_EVIDENCE } from "../state/degradations.js";
|
|
8
14
|
import type { FacetGroup } from "../state/facets.js";
|
|
9
15
|
import { useHostFacetLabels } from "./useFacetLabels.js";
|
|
10
16
|
import type { FacetLabelResolver } from "./useFacetLabels.js";
|
|
@@ -51,6 +57,34 @@ export interface FacetPanelBag {
|
|
|
51
57
|
readonly currency: string | undefined;
|
|
52
58
|
/** Size of the largest counted set — the number `approximate` is about. */
|
|
53
59
|
readonly candidates: number;
|
|
60
|
+
/**
|
|
61
|
+
* Groups the counter COUNTED and then held back, because their buckets
|
|
62
|
+
* describe too little of the result set. Not empty means the panel may
|
|
63
|
+
* NOT say "this search offers no filters" — it says how many, instead.
|
|
64
|
+
*/
|
|
65
|
+
readonly withheld: readonly FacetWithheldGroup[];
|
|
66
|
+
/**
|
|
67
|
+
* The categories this answer's candidate set is made of, busiest first —
|
|
68
|
+
* the evidence the plan was drawn from, and the counted category filter a
|
|
69
|
+
* text search has no other way to offer. Empty when the plan is the
|
|
70
|
+
* queried category's own. `category` is the same slash-joined id path
|
|
71
|
+
* `SearchQueryState.category` takes.
|
|
72
|
+
*/
|
|
73
|
+
readonly categories: readonly FacetCategoryCount[];
|
|
74
|
+
/**
|
|
75
|
+
* Where the plan came from: `"category"` (the queried category's own
|
|
76
|
+
* authored schema) or `"evidence"` (the categories the candidate set
|
|
77
|
+
* actually contains).
|
|
78
|
+
*/
|
|
79
|
+
readonly plan: string;
|
|
80
|
+
/**
|
|
81
|
+
* True when the server could not work out a plan AT ALL — the engine has
|
|
82
|
+
* no `category_counts` verb (`degraded: ["facet_plan_evidence"]`). An
|
|
83
|
+
* empty panel then means "we do not know", so a skin must not print
|
|
84
|
+
* "there are no filters" over it; the degradation notice is what tells
|
|
85
|
+
* the reader.
|
|
86
|
+
*/
|
|
87
|
+
readonly planUnavailable: boolean;
|
|
54
88
|
/** Facet values + ranges + geo currently applied. */
|
|
55
89
|
readonly activeFilters: number;
|
|
56
90
|
|
|
@@ -67,6 +101,9 @@ const EMPTY_META: FacetMeta = {
|
|
|
67
101
|
skipped: [],
|
|
68
102
|
dropped_filters: [],
|
|
69
103
|
core_ranges: [],
|
|
104
|
+
plan: "category",
|
|
105
|
+
withheld: [],
|
|
106
|
+
categories: [],
|
|
70
107
|
};
|
|
71
108
|
|
|
72
109
|
/**
|
|
@@ -174,6 +211,17 @@ export function useFacetPanel(props: {
|
|
|
174
211
|
?.card?.["currency"] as string | undefined
|
|
175
212
|
: undefined,
|
|
176
213
|
candidates: meta.candidates,
|
|
214
|
+
withheld: meta.withheld ?? [],
|
|
215
|
+
categories: meta.categories ?? [],
|
|
216
|
+
plan: meta.plan ?? "category",
|
|
217
|
+
// Read off the raw list rather than through `parseDegradations`: the
|
|
218
|
+
// question is whether THIS answer supports the sentence "no filters",
|
|
219
|
+
// and the banner's own parsing is a different job on a different
|
|
220
|
+
// surface. Guarded on the READY state, never flattened — a query still
|
|
221
|
+
// in flight has not said anything about its facet plan.
|
|
222
|
+
planUnavailable:
|
|
223
|
+
envelope.status === "ready" &&
|
|
224
|
+
envelope.data.degraded.includes(FACET_PLAN_EVIDENCE),
|
|
177
225
|
activeFilters,
|
|
178
226
|
toggle: toggleFilter,
|
|
179
227
|
setRange,
|
|
@@ -71,6 +71,21 @@ export interface SearchStateBag {
|
|
|
71
71
|
* Draw it as an invitation ("near me"), never as state.
|
|
72
72
|
*/
|
|
73
73
|
readonly geoOffer: SearchGeo | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the location this search is USING is the one the host offered —
|
|
76
|
+
* i.e. the visitor pressed "near me" (or followed a link built by somebody
|
|
77
|
+
* who did) and has not moved the pin since.
|
|
78
|
+
*
|
|
79
|
+
* A fact about provenance, and only the provider holds it: `state.geo` is a
|
|
80
|
+
* centre and a radius, and every way of arriving at a centre produces the
|
|
81
|
+
* same three numbers. Without it a summary line has to guess, and the guess
|
|
82
|
+
* it shipped was "a chosen place on the map" — said to a person who had
|
|
83
|
+
* pressed a button and never opened a map.
|
|
84
|
+
*
|
|
85
|
+
* `false` whenever there is no offer to compare against, which includes
|
|
86
|
+
* every search on a host that offers none.
|
|
87
|
+
*/
|
|
88
|
+
readonly geoIsOffer: boolean;
|
|
74
89
|
|
|
75
90
|
setText(q: string): void;
|
|
76
91
|
setSort(sort: string | null): void;
|
|
@@ -92,6 +107,29 @@ export interface SearchStateBag {
|
|
|
92
107
|
patch(patch: SearchStatePatch): void;
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How close two coordinates have to be to be the same place, in degrees.
|
|
112
|
+
*
|
|
113
|
+
* The applied location makes a round trip through the query string, so the
|
|
114
|
+
* numbers that come back are the ones `String(lat)` produced rather than the
|
|
115
|
+
* float the device handed over. 1e-6 degrees is ~11cm — far below any
|
|
116
|
+
* position a browser reports and far above any rounding the codec introduces.
|
|
117
|
+
*/
|
|
118
|
+
const SAME_PLACE_EPSILON = 1e-6;
|
|
119
|
+
|
|
120
|
+
/** Is the applied location the offered one? See {@link SearchStateBag.geoIsOffer}. */
|
|
121
|
+
function sameCenter(
|
|
122
|
+
applied: SearchGeo | undefined,
|
|
123
|
+
offered: SearchGeo | undefined
|
|
124
|
+
): boolean {
|
|
125
|
+
if (applied === undefined || offered === undefined) return false;
|
|
126
|
+
if (applied.kind !== "center" || offered.kind !== "center") return false;
|
|
127
|
+
return (
|
|
128
|
+
Math.abs(applied.lat - offered.lat) < SAME_PLACE_EPSILON &&
|
|
129
|
+
Math.abs(applied.lon - offered.lon) < SAME_PLACE_EPSILON
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
const StateContext = createContext<SearchStateBag | null>(null);
|
|
96
134
|
|
|
97
135
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
@@ -178,6 +216,23 @@ export function SearchStateProvider(
|
|
|
178
216
|
|
|
179
217
|
const bag = useMemo<SearchStateBag>(() => {
|
|
180
218
|
const state = parsed.state;
|
|
219
|
+
/**
|
|
220
|
+
* The offer, carrying the radius the URL already asked for.
|
|
221
|
+
*
|
|
222
|
+
* `?radius_km=300` with no `lat`/`lon` narrows nothing — there is no
|
|
223
|
+
* point to measure from — but it IS a number the person typed, and the
|
|
224
|
+
* offer used to ignore it twice over: the button advertised the host's
|
|
225
|
+
* own 25km, and pressing it wrote 25 into the address over the 300 that
|
|
226
|
+
* was already there. Three things now agree, which is the whole of the
|
|
227
|
+
* fix: what the link asked for, what the button says, and what pressing
|
|
228
|
+
* it does. A bbox offer is left alone — a box has no radius to carry.
|
|
229
|
+
*/
|
|
230
|
+
const offer: SearchGeo | undefined =
|
|
231
|
+
geoOffer !== undefined &&
|
|
232
|
+
geoOffer.kind === "center" &&
|
|
233
|
+
parsed.orphanRadiusKm !== undefined
|
|
234
|
+
? { ...geoOffer, radiusKm: parsed.orphanRadiusKm }
|
|
235
|
+
: geoOffer;
|
|
181
236
|
const apply = (
|
|
182
237
|
next: SearchQueryState,
|
|
183
238
|
options?: { readonly replace?: boolean }
|
|
@@ -193,7 +248,8 @@ export function SearchStateProvider(
|
|
|
193
248
|
// — there is nothing left to offer, and a control that kept drawing
|
|
194
249
|
// "near me" beside an applied location would be inviting a person to
|
|
195
250
|
// re-answer a question they can already see the answer to.
|
|
196
|
-
geoOffer: state.geo === undefined ?
|
|
251
|
+
geoOffer: state.geo === undefined ? offer : undefined,
|
|
252
|
+
geoIsOffer: sameCenter(state.geo, offer),
|
|
197
253
|
|
|
198
254
|
// Typing replaces rather than pushes: one history entry per letter
|
|
199
255
|
// would make Back useless, which is the control the spec's acceptance
|
|
@@ -209,10 +265,10 @@ export function SearchStateProvider(
|
|
|
209
265
|
apply(patchSearchState(state, { geo }));
|
|
210
266
|
},
|
|
211
267
|
acceptGeoOffer: () => {
|
|
212
|
-
if (
|
|
268
|
+
if (offer === undefined || state.geo !== undefined) return;
|
|
213
269
|
// A PUSH, like any other filter the person applies: Back takes the
|
|
214
270
|
// narrowing off again, which is the same promise every chip makes.
|
|
215
|
-
apply(patchSearchState(state, { geo:
|
|
271
|
+
apply(patchSearchState(state, { geo: offer }));
|
|
216
272
|
},
|
|
217
273
|
// A page size is a preference, not a step through the results.
|
|
218
274
|
setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* 4. the raw value.
|
|
33
33
|
*
|
|
34
34
|
* `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
|
|
35
|
-
* and it
|
|
35
|
+
* and it marks an option it could not name `labelSource: "none"` — which is
|
|
36
36
|
* what makes "did anyone name this?" answerable without a second lookup. Only
|
|
37
37
|
* those values are asked about, so the host is never called for a value the
|
|
38
38
|
* server or the schema already captioned, and a resolver that returns nothing
|
|
@@ -98,7 +98,7 @@ const NO_REQUESTS: readonly FacetLabelRequest[] = [];
|
|
|
98
98
|
/** The values of one group nobody has named — see the precedence note above. */
|
|
99
99
|
function unresolvedValues(group: FacetGroup): readonly string[] {
|
|
100
100
|
return group.options
|
|
101
|
-
.filter((option) => option.
|
|
101
|
+
.filter((option) => option.labelSource === "none")
|
|
102
102
|
.map((option) => option.value)
|
|
103
103
|
.sort((a, b) => a.localeCompare(b));
|
|
104
104
|
}
|
|
@@ -157,14 +157,14 @@ export function useHostFacetLabels(
|
|
|
157
157
|
return {
|
|
158
158
|
...group,
|
|
159
159
|
options: group.options.map((option) => {
|
|
160
|
-
// Precedence again, enforced rather than assumed: an option
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
if (option.
|
|
160
|
+
// Precedence again, enforced rather than assumed: an option the
|
|
161
|
+
// server or the schema already named is not the host's to
|
|
162
|
+
// overwrite. The source says so; the strings cannot.
|
|
163
|
+
if (option.labelSource !== "none") return option;
|
|
164
164
|
const caption = named[option.value];
|
|
165
165
|
return caption === undefined || caption.length === 0
|
|
166
166
|
? option
|
|
167
|
-
: { ...option, label: caption };
|
|
167
|
+
: { ...option, label: caption, labelSource: "host" as const };
|
|
168
168
|
}),
|
|
169
169
|
};
|
|
170
170
|
})
|
package/src/i18n/es.ts
CHANGED
|
@@ -75,6 +75,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
75
75
|
"Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
|
|
76
76
|
"search.facets.skipped":
|
|
77
77
|
"Estos filtros no se contaron para esta búsqueda: {slugs}",
|
|
78
|
+
"search.facets.withheld.one":
|
|
79
|
+
"{count} filtro se aplica a muy pocos de estos resultados",
|
|
80
|
+
"search.facets.withheld.other":
|
|
81
|
+
"{count} filtros se aplican a muy pocos de estos resultados",
|
|
78
82
|
"search.facets.not_counted": "sin contar",
|
|
79
83
|
"search.facets.drill_down_hint":
|
|
80
84
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
@@ -89,6 +93,12 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
89
93
|
"search.facets.show_less": "Ver menos",
|
|
90
94
|
"search.facets.search": "Buscar un filtro",
|
|
91
95
|
"search.facets.search_empty": "Ningún filtro coincide con esto",
|
|
96
|
+
"search.facets.dictionary_search": "Buscar un valor",
|
|
97
|
+
"search.facets.dictionary_empty": "Ningún valor coincide con esto",
|
|
98
|
+
"search.facets.dictionary_chosen": "Elegido",
|
|
99
|
+
"search.facets.popular_all": "Todos",
|
|
100
|
+
"search.partition.all": "Todos",
|
|
101
|
+
"search.partition.label": "Sección",
|
|
92
102
|
"search.facets.match_count.one": "{count} anuncio coincide",
|
|
93
103
|
"search.facets.match_count.other": "{count} anuncios coinciden",
|
|
94
104
|
"search.facets.range_invalid":
|
|
@@ -125,12 +135,14 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
125
135
|
|
|
126
136
|
"search.geo.title": "Ubicación",
|
|
127
137
|
"search.geo.radius_km": "A menos de {km} km",
|
|
138
|
+
"search.geo.radius_km_short": "{km} km",
|
|
128
139
|
"search.geo.radius_label": "Radio, km",
|
|
129
140
|
"search.geo.clear": "En cualquier lugar",
|
|
130
141
|
"search.geo.near_me": "Cerca de mí",
|
|
131
142
|
"search.geo.everywhere": "Buscando en todas partes",
|
|
132
143
|
"search.geo.box": "Dentro del área mostrada",
|
|
133
144
|
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
|
145
|
+
"search.geo.near_you": "Cerca de ti",
|
|
134
146
|
|
|
135
147
|
"search.url.issues_title": "Parte de este enlace no se pudo leer",
|
|
136
148
|
"search.url.issue.not_a_number":
|
|
@@ -141,6 +153,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
141
153
|
"el área del mapa de este enlace está incompleta, así que se ignoró",
|
|
142
154
|
"search.url.issue.range_malformed":
|
|
143
155
|
"al rango «{param}» de este enlace le faltan números, así que se ignoró",
|
|
156
|
+
"search.url.issue.radius_without_place":
|
|
157
|
+
"este enlace pide un radio pero no nombra ningún lugar, así que aún no se acota nada — elige un lugar y se aplicará ese mismo radio",
|
|
144
158
|
|
|
145
159
|
"search.degraded.title": "Lo que esta búsqueda no pudo hacer",
|
|
146
160
|
"search.degraded.typo_tolerance":
|
|
@@ -151,6 +165,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
151
165
|
"search.degraded.exact_facet_counts": "Los recuentos de filtros son aproximados.",
|
|
152
166
|
"search.degraded.category_rollup":
|
|
153
167
|
"Pueden faltar subcategorías en estos resultados: el servicio de categorías no respondió.",
|
|
168
|
+
"search.degraded.facet_plan_evidence":
|
|
169
|
+
"No pudimos determinar qué filtros encajan con estos resultados, así que puede haber más de los que muestra el panel.",
|
|
154
170
|
"search.degraded.scorer":
|
|
155
171
|
"El parámetro de ranking «{scorer}» no se aplicó: el motor configurado no puede evaluarlo.",
|
|
156
172
|
"search.degraded.unknown":
|