@stapel/search-react 0.31.0 → 0.32.1
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 +103 -0
- package/dist/api/generated/schema.d.ts +69 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +118 -33
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +13 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +18 -1
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +25 -0
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +27 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +100 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +30 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +35 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PopularValues.d.ts +49 -2
- package/dist/default/PopularValues.d.ts.map +1 -1
- package/dist/default/PopularValues.js +57 -3
- package/dist/default/PopularValues.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts +25 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +77 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +28 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +13 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +10 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +1 -1
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -10
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +19 -8
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/index.d.ts +4 -4
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +7 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +2 -1
- 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 +0 -0
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +13 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +13 -0
- package/dist/state/facets.js.map +1 -1
- package/dist/state/panel.d.ts +79 -0
- package/dist/state/panel.d.ts.map +1 -0
- package/dist/state/panel.js +75 -0
- package/dist/state/panel.js.map +1 -0
- package/dist/state/ranges.d.ts +88 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +96 -20
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +4 -4
- package/manifest.json +12 -2
- package/nav-manifest.json +1 -1
- package/package.json +9 -9
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +69 -6
- package/src/api/types.ts +132 -32
- package/src/default/FacetGroupControl.tsx +29 -1
- package/src/default/FacetPanelPane.tsx +257 -124
- package/src/default/FilterChips.tsx +51 -1
- package/src/default/PopularValues.tsx +88 -4
- package/src/default/RangeFilterRow.tsx +117 -20
- package/src/default/SearchPage.tsx +46 -4
- package/src/default/SearchResultsPane.tsx +13 -0
- package/src/default/SortSelect.tsx +29 -19
- package/src/default/index.ts +8 -1
- package/src/headless/FacetPanel.tsx +17 -2
- package/src/headless/SearchStateProvider.tsx +23 -0
- package/src/index.ts +8 -0
- package/src/state/facets.ts +27 -0
- package/src/state/panel.ts +138 -0
- package/src/state/ranges.ts +209 -47
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
30
30
|
import { Button, Flex, Typography } from "antd";
|
|
31
31
|
import { useT } from "@stapel/core";
|
|
32
|
-
import { spacing } from "@stapel/tokens";
|
|
32
|
+
import { breakpoints, spacing } from "@stapel/tokens";
|
|
33
33
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
34
34
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
35
35
|
|
|
@@ -40,6 +40,66 @@ export const POPULAR_VALUES_LIMIT = 12;
|
|
|
40
40
|
* without turning a make into a two-line wrap. */
|
|
41
41
|
export const POPULAR_VALUES_COLUMNS = 3;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The width one column of this block needs: a make and its count, on one line,
|
|
45
|
+
* at the default type step. «Ford 1 204» is the measure; below it a value
|
|
46
|
+
* wraps under its own number and the block stops being scannable.
|
|
47
|
+
*/
|
|
48
|
+
export const POPULAR_VALUE_COLUMN_WIDTH = 200;
|
|
49
|
+
|
|
50
|
+
/** The class the container query is hung on. */
|
|
51
|
+
export const POPULAR_VALUES_CLASS = "stapel-popular-values";
|
|
52
|
+
|
|
53
|
+
/** The `href` the hoisted ladder sheet is deduplicated by (React 19). */
|
|
54
|
+
export const POPULAR_VALUES_STYLE_HREF = "stapel-popular-values";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The ladder `columns="responsive"` climbs — one rung per column, by the width
|
|
58
|
+
* of the BLOCK rather than of the window.
|
|
59
|
+
*
|
|
60
|
+
* The container is what decides, and it has to be: this block sits in the
|
|
61
|
+
* results column, which on a 1440px desktop is the window minus a 280px rail
|
|
62
|
+
* minus the gap. A media query would give it four columns at a width it never
|
|
63
|
+
* has, and one column inside a narrow host container that happens to sit on a
|
|
64
|
+
* wide screen. `container-type: inline-size` plus `@container` asks the only
|
|
65
|
+
* question that has an answer here — how wide is this block.
|
|
66
|
+
*
|
|
67
|
+
* The rungs: one column while the block is narrower than two of
|
|
68
|
+
* {@link POPULAR_VALUE_COLUMN_WIDTH}, then the two token breakpoints for the
|
|
69
|
+
* third and fourth. `tablet` and `desktop` are `@stapel/tokens`' own numbers,
|
|
70
|
+
* and using them here is the same statement they make everywhere else — this
|
|
71
|
+
* is where a layout of this density gains a column.
|
|
72
|
+
*/
|
|
73
|
+
export const POPULAR_VALUES_LADDER: readonly {
|
|
74
|
+
readonly minInlineSize: number;
|
|
75
|
+
readonly columns: number;
|
|
76
|
+
}[] = [
|
|
77
|
+
{ minInlineSize: POPULAR_VALUE_COLUMN_WIDTH * 2, columns: 2 },
|
|
78
|
+
{ minInlineSize: breakpoints.tablet, columns: 3 },
|
|
79
|
+
{ minInlineSize: breakpoints.desktop, columns: 4 },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The ladder as CSS. One `@container` rule per rung, ascending, so the widest
|
|
84
|
+
* matching rung is the one that wins by ordinary cascade order.
|
|
85
|
+
*
|
|
86
|
+
* Emitted as a hoisted `<style>` rather than an inline style because a
|
|
87
|
+
* container query is unreachable from one — the same reason `<SearchPage>`
|
|
88
|
+
* hoists its rail sheet.
|
|
89
|
+
*/
|
|
90
|
+
export function popularValuesLadderCss(): string {
|
|
91
|
+
const block = `.${POPULAR_VALUES_CLASS}`;
|
|
92
|
+
return [
|
|
93
|
+
`${block}{container-type:inline-size}`,
|
|
94
|
+
`${block}>[data-popular-columns]{column-count:1}`,
|
|
95
|
+
...POPULAR_VALUES_LADDER.map(
|
|
96
|
+
(rung) =>
|
|
97
|
+
`@container (min-width: ${String(rung.minInlineSize)}px)` +
|
|
98
|
+
`{${block}>[data-popular-columns]{column-count:${String(rung.columns)}}}`
|
|
99
|
+
),
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
43
103
|
/** A value with no evidence behind it is not a popular value. Uncounted
|
|
44
104
|
* options carry `count: null` and are dropped here rather than printed with a
|
|
45
105
|
* blank where the number belongs — the block IS the numbers. */
|
|
@@ -76,8 +136,15 @@ export interface PopularValuesProps {
|
|
|
76
136
|
readonly hidden?: boolean;
|
|
77
137
|
/** How many values. Default {@link POPULAR_VALUES_LIMIT}. */
|
|
78
138
|
readonly limit?: number;
|
|
79
|
-
/**
|
|
80
|
-
|
|
139
|
+
/**
|
|
140
|
+
* How many columns. Default {@link POPULAR_VALUES_COLUMNS}.
|
|
141
|
+
*
|
|
142
|
+
* `"responsive"` climbs {@link POPULAR_VALUES_LADDER} by the width of the
|
|
143
|
+
* BLOCK — 1, 2, 3, 4 — instead of taking one number for every surface. The
|
|
144
|
+
* numeric form stays, and stays the default: a host that has already decided
|
|
145
|
+
* its layout should not have that decision taken back by a query.
|
|
146
|
+
*/
|
|
147
|
+
readonly columns?: number | "responsive";
|
|
81
148
|
/** The block's heading. Defaults to the group's own label; `null` draws
|
|
82
149
|
* none, for a surface that has already named the axis. */
|
|
83
150
|
readonly heading?: ReactNode;
|
|
@@ -93,19 +160,36 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
|
93
160
|
const options = popularOptions(group, props.limit ?? POPULAR_VALUES_LIMIT);
|
|
94
161
|
if (options.length === 0) return null;
|
|
95
162
|
|
|
163
|
+
const responsive = props.columns === "responsive";
|
|
164
|
+
|
|
96
165
|
return (
|
|
97
166
|
<Flex
|
|
98
167
|
vertical
|
|
99
168
|
gap={spacing[2]}
|
|
169
|
+
{...(responsive ? { className: POPULAR_VALUES_CLASS } : {})}
|
|
100
170
|
data-testid={`popular-values-${group.slug}`}
|
|
101
171
|
data-label-source={group.labelSource}
|
|
172
|
+
data-columns={responsive ? "responsive" : String(props.columns ?? POPULAR_VALUES_COLUMNS)}
|
|
102
173
|
>
|
|
174
|
+
{/* The ladder, hoisted and deduped by `href`: a container query has no
|
|
175
|
+
inline form. Only where it is asked for — a block with a fixed
|
|
176
|
+
column count needs no sheet at all. */}
|
|
177
|
+
{responsive && (
|
|
178
|
+
<style href={POPULAR_VALUES_STYLE_HREF} precedence="default">
|
|
179
|
+
{popularValuesLadderCss()}
|
|
180
|
+
</style>
|
|
181
|
+
)}
|
|
103
182
|
{props.heading !== null && (
|
|
104
183
|
<Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
|
|
105
184
|
)}
|
|
106
185
|
<div
|
|
186
|
+
{...(responsive ? { "data-popular-columns": "" } : {})}
|
|
107
187
|
style={{
|
|
108
|
-
|
|
188
|
+
// The rungs live in the sheet above; an inline `column-count` here
|
|
189
|
+
// would win against every one of them.
|
|
190
|
+
...(responsive
|
|
191
|
+
? {}
|
|
192
|
+
: { columnCount: props.columns ?? POPULAR_VALUES_COLUMNS }),
|
|
109
193
|
columnGap: spacing[4],
|
|
110
194
|
}}
|
|
111
195
|
>
|
|
@@ -18,6 +18,24 @@
|
|
|
18
18
|
* the picker never had — which is also what makes "from > to" refusable
|
|
19
19
|
* instead of merely empty. A blur that changed nothing sends nothing, and
|
|
20
20
|
* Enter followed by the blur it does not itself cause never double-commits.
|
|
21
|
+
*
|
|
22
|
+
* ── One Apply for the panel, not one per row ──────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* That is the row STANDING ALONE. Inside a filter panel it is one of several,
|
|
25
|
+
* and a rail with a price, a year and a mileage drew three identical
|
|
26
|
+
* "Apply" buttons stacked down a 280px column — one per row, each
|
|
27
|
+
* committing its own two fields, so narrowing by two axes was two searches and
|
|
28
|
+
* two history entries, and the button beside the row a person had just typed
|
|
29
|
+
* into looked like the one that would apply everything.
|
|
30
|
+
*
|
|
31
|
+
* So a panel passes {@link RangeFilterRowProps.onDraft} and owns the button:
|
|
32
|
+
* the row then REPORTS its draft on every change and commits nothing by
|
|
33
|
+
* itself. Blur stops committing (there is a button now, and blurring one field
|
|
34
|
+
* to reach the next is not a decision), Enter reaches the panel's own commit
|
|
35
|
+
* through {@link RangeFilterRowProps.onCommit}, and "Clear" stays on the
|
|
36
|
+
* row because removing one applied constraint is not the same act as applying
|
|
37
|
+
* a draft. The standalone shape is unchanged, and is still what a host mounting
|
|
38
|
+
* one row gets.
|
|
21
39
|
*/
|
|
22
40
|
import { useRef, useState } from "react";
|
|
23
41
|
import type { ReactElement } from "react";
|
|
@@ -77,10 +95,36 @@ export function RangeRowSkeleton(): ReactElement {
|
|
|
77
95
|
);
|
|
78
96
|
}
|
|
79
97
|
|
|
98
|
+
/** What one row reports to a panel that owns the Apply button. */
|
|
99
|
+
export interface RangeDraft {
|
|
100
|
+
/** The two ends as typed, or `null` for "clear this slug". */
|
|
101
|
+
readonly range: SearchRange | null;
|
|
102
|
+
/** `false` for `100..50` — a pair the server would answer zero for, which
|
|
103
|
+
* reads as "there is nothing like this" instead of "you typed it
|
|
104
|
+
* backwards". A panel refuses to apply while any draft is unusable. */
|
|
105
|
+
readonly usable: boolean;
|
|
106
|
+
/** `true` when the draft differs from what the URL already carries — what
|
|
107
|
+
* makes the panel's button primary, and what stops an Apply over untouched
|
|
108
|
+
* rows sending anything. */
|
|
109
|
+
readonly dirty: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
80
112
|
export interface RangeFilterRowProps {
|
|
81
113
|
readonly group: RangeGroup;
|
|
82
114
|
/** `null` clears the slug's range entirely. */
|
|
83
115
|
readonly onApply: (slug: string, range: SearchRange | null) => void;
|
|
116
|
+
/**
|
|
117
|
+
* Report this row's draft instead of owning an Apply button — see the
|
|
118
|
+
* module note. Called on every change, and once on mount is NOT promised: a
|
|
119
|
+
* panel starts from "nothing pending", which is what an untouched row means.
|
|
120
|
+
*/
|
|
121
|
+
readonly onDraft?: (slug: string, draft: RangeDraft) => void;
|
|
122
|
+
/**
|
|
123
|
+
* Commit everything the panel has collected. Only reachable in the collected
|
|
124
|
+
* shape, and only from Enter — the gesture that has always meant "I have
|
|
125
|
+
* finished typing this".
|
|
126
|
+
*/
|
|
127
|
+
readonly onCommit?: () => void;
|
|
84
128
|
}
|
|
85
129
|
|
|
86
130
|
function toDraft(value: string | undefined): string {
|
|
@@ -243,6 +287,10 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
243
287
|
const apply: ActionAvailability = usable
|
|
244
288
|
? actionAvailable()
|
|
245
289
|
: actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
|
|
290
|
+
// Who owns the Apply button — see the module note. `onDraft` is the panel
|
|
291
|
+
// saying "I have one for all of us".
|
|
292
|
+
const onDraft = props.onDraft;
|
|
293
|
+
const collected = onDraft !== undefined;
|
|
246
294
|
|
|
247
295
|
const commit = (): void => {
|
|
248
296
|
if (!usable) return;
|
|
@@ -252,6 +300,45 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
252
300
|
props.onApply(group.slug, empty ? null : draft);
|
|
253
301
|
};
|
|
254
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Both halves of a change, in the shape the row is in.
|
|
305
|
+
*
|
|
306
|
+
* Standalone, a bound moves the local draft and nothing else until Apply,
|
|
307
|
+
* Enter or blur. Collected, the same move is also REPORTED — synchronously,
|
|
308
|
+
* from the event, with the values the change produces rather than the ones
|
|
309
|
+
* state still holds, because `setFrom` has not landed yet and a panel told
|
|
310
|
+
* the previous value would apply the number before last.
|
|
311
|
+
*/
|
|
312
|
+
const change = (bound: "from" | "to", value: string): void => {
|
|
313
|
+
if (bound === "from") setFrom(value);
|
|
314
|
+
else setTo(value);
|
|
315
|
+
if (onDraft === undefined) return;
|
|
316
|
+
const nextFrom = bound === "from" ? value : from;
|
|
317
|
+
const nextTo = bound === "to" ? value : to;
|
|
318
|
+
const next: SearchRange = {
|
|
319
|
+
...(nextFrom !== "" ? { from: nextFrom } : {}),
|
|
320
|
+
...(nextTo !== "" ? { to: nextTo } : {}),
|
|
321
|
+
};
|
|
322
|
+
const blank = nextFrom === "" && nextTo === "";
|
|
323
|
+
onDraft(group.slug, {
|
|
324
|
+
range: blank ? null : next,
|
|
325
|
+
usable: isRangeUsable(next),
|
|
326
|
+
dirty: `${nextFrom}..${nextTo}` !== current,
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/** Enter. The panel's commit when there is one, this row's otherwise. */
|
|
331
|
+
const enter = (): void => {
|
|
332
|
+
if (collected) props.onCommit?.();
|
|
333
|
+
else commit();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Blur. Nothing at all once a button owns the decision — leaving a field to
|
|
337
|
+
* reach the next one is not "apply this". */
|
|
338
|
+
const blur = (): void => {
|
|
339
|
+
if (!collected) commit();
|
|
340
|
+
};
|
|
341
|
+
|
|
255
342
|
const suffix = boundSuffix(format, group);
|
|
256
343
|
const unit = suffix === undefined ? "" : ` ${suffix}`;
|
|
257
344
|
// Thousands grouping inside the field, because a price is read in
|
|
@@ -294,15 +381,19 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
294
381
|
group={group}
|
|
295
382
|
bound="from"
|
|
296
383
|
value={from}
|
|
297
|
-
onChange={
|
|
298
|
-
|
|
384
|
+
onChange={(value) => {
|
|
385
|
+
change("from", value);
|
|
386
|
+
}}
|
|
387
|
+
onCommit={blur}
|
|
299
388
|
/>
|
|
300
389
|
<BoundPicker
|
|
301
390
|
group={group}
|
|
302
391
|
bound="to"
|
|
303
392
|
value={to}
|
|
304
|
-
onChange={
|
|
305
|
-
|
|
393
|
+
onChange={(value) => {
|
|
394
|
+
change("to", value);
|
|
395
|
+
}}
|
|
396
|
+
onCommit={blur}
|
|
306
397
|
/>
|
|
307
398
|
</>
|
|
308
399
|
) : (
|
|
@@ -320,10 +411,10 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
320
411
|
{...(group.max !== undefined ? { max: group.max } : {})}
|
|
321
412
|
{...(group.step !== undefined ? { step: group.step } : {})}
|
|
322
413
|
onChange={(value) => {
|
|
323
|
-
|
|
414
|
+
change("from", value === null || value === undefined ? "" : String(value));
|
|
324
415
|
}}
|
|
325
|
-
onPressEnter={
|
|
326
|
-
onBlur={
|
|
416
|
+
onPressEnter={enter}
|
|
417
|
+
onBlur={blur}
|
|
327
418
|
/>
|
|
328
419
|
<InputNumber
|
|
329
420
|
value={to === "" ? null : Number(to)}
|
|
@@ -338,10 +429,10 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
338
429
|
{...(group.max !== undefined ? { max: group.max } : {})}
|
|
339
430
|
{...(group.step !== undefined ? { step: group.step } : {})}
|
|
340
431
|
onChange={(value) => {
|
|
341
|
-
|
|
432
|
+
change("to", value === null || value === undefined ? "" : String(value));
|
|
342
433
|
}}
|
|
343
|
-
onPressEnter={
|
|
344
|
-
onBlur={
|
|
434
|
+
onPressEnter={enter}
|
|
435
|
+
onBlur={blur}
|
|
345
436
|
/>
|
|
346
437
|
</>
|
|
347
438
|
)}
|
|
@@ -351,22 +442,28 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
351
442
|
button exists to submit (class C-NOPRIMARY). And no `size="small"`:
|
|
352
443
|
a filter row that a phone cannot hit is not a filter row, and the
|
|
353
444
|
shared `SkinTheme` only raises the DEFAULT control height to 44. */}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
445
|
+
{!collected && (
|
|
446
|
+
<GatedButton
|
|
447
|
+
gate={apply}
|
|
448
|
+
type={usable && !empty ? "primary" : "default"}
|
|
449
|
+
testId={`facet-range-${group.slug}-apply`}
|
|
450
|
+
data-analytics="none"
|
|
451
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
452
|
+
onClick={commit}
|
|
453
|
+
>
|
|
454
|
+
{t(SEARCH_I18N_KEYS.facetsRangeApply)}
|
|
455
|
+
</GatedButton>
|
|
456
|
+
)}
|
|
364
457
|
{group.active && (
|
|
365
458
|
<Button
|
|
366
459
|
data-testid={`facet-range-${group.slug}-clear`}
|
|
367
460
|
data-analytics="none"
|
|
368
461
|
data-analytics-reason="a filter is a read, not a flow step"
|
|
369
462
|
onClick={() => {
|
|
463
|
+
// The row's own draft goes with the constraint: leaving a
|
|
464
|
+
// pending `2015..` behind would let the panel's Apply put back
|
|
465
|
+
// exactly what this button just removed.
|
|
466
|
+
onDraft?.(group.slug, { range: null, usable: true, dirty: false });
|
|
370
467
|
props.onApply(group.slug, null);
|
|
371
468
|
}}
|
|
372
469
|
>
|
|
@@ -240,6 +240,16 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
240
240
|
* characteristic, and on a result list that is what a chip is.
|
|
241
241
|
*/
|
|
242
242
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
243
|
+
/**
|
|
244
|
+
* Draw the "Category" pane in the filter panel at all. Default `true`.
|
|
245
|
+
*
|
|
246
|
+
* `false` removes it entirely — see
|
|
247
|
+
* {@link FacetPanelPaneProps.categoryFilter}. The page passes it on and
|
|
248
|
+
* stops counting the category as a reason to keep the filter column open,
|
|
249
|
+
* so a leaf whose only other filter is a price does not get a column
|
|
250
|
+
* containing one pane the surface asked not to draw.
|
|
251
|
+
*/
|
|
252
|
+
readonly categoryFilter?: boolean;
|
|
243
253
|
/**
|
|
244
254
|
* What the current category is CALLED — the chip's own text. The pair holds
|
|
245
255
|
* a path of slugs and no way to turn one into a catalogue name; absent, the
|
|
@@ -270,7 +280,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
270
280
|
*
|
|
271
281
|
* Defaulted PER LAYOUT rather than left to the panel's own default, because
|
|
272
282
|
* the two frames want opposite shapes and only this component knows which
|
|
273
|
-
* one it is drawing: the desktop rail gets `"field"` (a select-style
|
|
283
|
+
* one it is drawing: the desktop rail gets `"field"` (a select-style "Any"
|
|
274
284
|
* that opens the searchable list — a 418-value vocabulary held open in a
|
|
275
285
|
* 280px column is the whole column), the phone sheet gets `"sheet"` — the
|
|
276
286
|
* same trigger row, opening a nested picker with a search box, a
|
|
@@ -340,6 +350,23 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
340
350
|
* describes the whole page, not the results column of it.
|
|
341
351
|
*/
|
|
342
352
|
readonly resultsHeader?: ReactNode;
|
|
353
|
+
/**
|
|
354
|
+
* The top of the RESULTS COLUMN, above the toolbar and the heading row.
|
|
355
|
+
*
|
|
356
|
+
* The fifth slot, and the four before it were each checked first:
|
|
357
|
+
* `resultsHeader` spans the whole page (both columns on a desktop), which is
|
|
358
|
+
* right for a location row and wrong for anything that belongs over the list
|
|
359
|
+
* — a category's own description, a promoted band, a "12 new since
|
|
360
|
+
* yesterday" line all describe the RESULTS and would otherwise sit over the
|
|
361
|
+
* filter rail as well; `breadcrumb` is a walk up the category tree and
|
|
362
|
+
* renders above the heading; `resultsHeading` is the caption itself; the
|
|
363
|
+
* pane's `toolbar` is the row of controls this slot sits above.
|
|
364
|
+
*
|
|
365
|
+
* Rendered inside the column in BOTH layouts, so on a phone it is above the
|
|
366
|
+
* sort row and on a desktop it is above the sort row of the results column
|
|
367
|
+
* only.
|
|
368
|
+
*/
|
|
369
|
+
readonly resultsLead?: ReactNode;
|
|
343
370
|
/**
|
|
344
371
|
* Draw the APPLIED filter row in the results header — one chip per applied
|
|
345
372
|
* value and per applied range, each of which removes it
|
|
@@ -459,6 +486,8 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
459
486
|
|
|
460
487
|
interface SearchPageBodyProps {
|
|
461
488
|
readonly renderCard?: SearchCardRenderer;
|
|
489
|
+
readonly categoryFilter?: boolean;
|
|
490
|
+
readonly resultsLead?: ReactNode;
|
|
462
491
|
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
463
492
|
readonly visibleGroups?: number | null;
|
|
464
493
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
@@ -568,9 +597,13 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
568
597
|
// show.
|
|
569
598
|
facets.activeFilters === 0 &&
|
|
570
599
|
ranges.length === 0 &&
|
|
571
|
-
|
|
600
|
+
// A category pane the surface turned off is not a control on this rail,
|
|
601
|
+
// however narrowed the search is: the way back out of a leaf is then the
|
|
602
|
+
// breadcrumb or the tiles above, not a filter (`categoryFilter`).
|
|
603
|
+
(props.categoryFilter === false ||
|
|
604
|
+
(state.category === undefined &&
|
|
605
|
+
props.renderCategoryFilter === undefined)) &&
|
|
572
606
|
state.lang === undefined &&
|
|
573
|
-
props.renderCategoryFilter === undefined &&
|
|
574
607
|
(props.languages ?? []).length === 0;
|
|
575
608
|
const showFilters = filtersHeader !== undefined || !filtersEmpty;
|
|
576
609
|
|
|
@@ -616,6 +649,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
616
649
|
{...(props.renderCategoryFilter !== undefined
|
|
617
650
|
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
618
651
|
: {})}
|
|
652
|
+
{...(props.categoryFilter !== undefined
|
|
653
|
+
? { categoryFilter: props.categoryFilter }
|
|
654
|
+
: {})}
|
|
619
655
|
{...(props.skippedNotice !== undefined
|
|
620
656
|
? { skippedNotice: props.skippedNotice }
|
|
621
657
|
: {})}
|
|
@@ -661,6 +697,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
661
697
|
const results = (
|
|
662
698
|
<SearchResultsPane
|
|
663
699
|
toolbar={toolbar}
|
|
700
|
+
{...(props.resultsLead !== undefined ? { lead: props.resultsLead } : {})}
|
|
664
701
|
{...(phoneToolbar ? { header: "compact" as const } : {})}
|
|
665
702
|
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
666
703
|
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
@@ -776,7 +813,8 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
776
813
|
/* The catalogue picker becomes the row's leading chip. The panel
|
|
777
814
|
behind the circle keeps its own copy of the control; both write
|
|
778
815
|
the same `category` parameter, so they cannot disagree. */
|
|
779
|
-
{...(props.renderCategoryFilter !== undefined
|
|
816
|
+
{...(props.renderCategoryFilter !== undefined &&
|
|
817
|
+
props.categoryFilter !== false
|
|
780
818
|
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
781
819
|
: {})}
|
|
782
820
|
{...(props.categoryLabel !== undefined
|
|
@@ -853,6 +891,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
853
891
|
footer,
|
|
854
892
|
filtersHeader,
|
|
855
893
|
resultsHeader,
|
|
894
|
+
resultsLead,
|
|
895
|
+
categoryFilter,
|
|
856
896
|
appliedChips,
|
|
857
897
|
otherCategories,
|
|
858
898
|
categoryName,
|
|
@@ -895,6 +935,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
895
935
|
{...(footer !== undefined ? { footer } : {})}
|
|
896
936
|
{...(filtersHeader !== undefined ? { filtersHeader } : {})}
|
|
897
937
|
{...(resultsHeader !== undefined ? { resultsHeader } : {})}
|
|
938
|
+
{...(resultsLead !== undefined ? { resultsLead } : {})}
|
|
939
|
+
{...(categoryFilter !== undefined ? { categoryFilter } : {})}
|
|
898
940
|
{...(appliedChips !== undefined ? { appliedChips } : {})}
|
|
899
941
|
{...(otherCategories !== undefined ? { otherCategories } : {})}
|
|
900
942
|
{...(categoryName !== undefined ? { categoryName } : {})}
|
|
@@ -180,6 +180,16 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
180
180
|
* toolbar in here instead of printing a second caption above the pane.
|
|
181
181
|
*/
|
|
182
182
|
readonly toolbar?: ReactNode;
|
|
183
|
+
/**
|
|
184
|
+
* The very top of the pane, ABOVE the heading row and its toolbar.
|
|
185
|
+
*
|
|
186
|
+
* For whatever describes THIS LIST and is not one of its controls: a
|
|
187
|
+
* category's own introduction, a promoted band, a "12 new since yesterday"
|
|
188
|
+
* line. It is inside the results column, which is the difference between
|
|
189
|
+
* this and `<SearchPage resultsHeader>` — that one spans both columns and
|
|
190
|
+
* would put the same block over the filter rail as well.
|
|
191
|
+
*/
|
|
192
|
+
readonly lead?: ReactNode;
|
|
183
193
|
/**
|
|
184
194
|
* What this list is CALLED on this surface. Default: "Results".
|
|
185
195
|
*
|
|
@@ -363,6 +373,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
363
373
|
<SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
|
|
364
374
|
{(bag) => (
|
|
365
375
|
<Flex vertical gap={spacing[4]}>
|
|
376
|
+
{props.lead !== undefined && (
|
|
377
|
+
<div data-testid="search-results-lead">{props.lead}</div>
|
|
378
|
+
)}
|
|
366
379
|
{props.header === "compact" ? (
|
|
367
380
|
<Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
|
|
368
381
|
<Typography.Title
|
|
@@ -52,17 +52,17 @@ export interface SortSelectProps {
|
|
|
52
52
|
* - the {@link SORT_SELECT_MIN_WIDTH} floor goes, so the control shares one
|
|
53
53
|
* row with whatever the surface puts beside it instead of pushing it to
|
|
54
54
|
* the next line;
|
|
55
|
-
* - the
|
|
56
|
-
* the option
|
|
55
|
+
* - the line under the control goes. The blocked option's REASON does not:
|
|
56
|
+
* it is on the option itself at every width now (see `optionsFor`), and
|
|
57
|
+
* what the compact form drops is the second, separate copy of it.
|
|
57
58
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* tap. Nothing is hidden; it is closer to the thing it explains.
|
|
59
|
+
* The reason lives on the row because that is where the person meets the
|
|
60
|
+
* refusal — a disabled row of the open list, which a screen reader reads out
|
|
61
|
+
* with the option and a thumb reads at the moment of the tap. This file
|
|
62
|
+
* exists because it used to live in a `title=` a phone can never surface,
|
|
63
|
+
* and a phone is exactly where "sort by distance" is greyed out most often;
|
|
64
|
+
* on a 390px toolbar the reason as a separate row would also cost a whole
|
|
65
|
+
* band of the viewport above the first result.
|
|
66
66
|
*/
|
|
67
67
|
readonly compact?: boolean;
|
|
68
68
|
}
|
|
@@ -94,7 +94,23 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
94
94
|
? actionAvailable()
|
|
95
95
|
: actionBlocked(SORT_DISTANCE_BLOCKED);
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The list, with the blocked row carrying its own reason — AT EVERY WIDTH.
|
|
99
|
+
*
|
|
100
|
+
* This used to be the compact arm's alone. The desktop arm relied on
|
|
101
|
+
* `GatedControl`'s sentence beside the closed select, which is where a
|
|
102
|
+
* screen reader meets it (`aria-describedby`) and is NOT where a person
|
|
103
|
+
* meets the refusal: they open the list, find one row greyed out, and the
|
|
104
|
+
* explanation for it is behind the open dropdown, above a control the
|
|
105
|
+
* dropdown is covering. A greyed row with no reason on it is the same defect
|
|
106
|
+
* this file was written to fix, one width up — it was a `title=` then and a
|
|
107
|
+
* line the dropdown hides now.
|
|
108
|
+
*
|
|
109
|
+
* So the row says it, and the sentence beside the control stays: one is the
|
|
110
|
+
* accessible description of the SELECT, the other is the label of the OPTION
|
|
111
|
+
* that is refused, and they are read in different moments.
|
|
112
|
+
*/
|
|
113
|
+
const optionsFor = (): {
|
|
98
114
|
readonly value: string;
|
|
99
115
|
readonly label: string;
|
|
100
116
|
readonly disabled: boolean;
|
|
@@ -105,13 +121,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
105
121
|
const blocked = value === "distance" && !hasCentre;
|
|
106
122
|
return {
|
|
107
123
|
value,
|
|
108
|
-
|
|
109
|
-
// `SortSelectProps.compact`. Elsewhere `GatedControl` renders it once,
|
|
110
|
-
// beside the control, and repeating it here would say it twice.
|
|
111
|
-
label:
|
|
112
|
-
blocked && props.compact === true && describedBy === undefined
|
|
113
|
-
? `${label} — ${t(SORT_DISTANCE_BLOCKED)}`
|
|
114
|
-
: label,
|
|
124
|
+
label: blocked ? `${label} — ${t(SORT_DISTANCE_BLOCKED)}` : label,
|
|
115
125
|
disabled: blocked,
|
|
116
126
|
};
|
|
117
127
|
});
|
|
@@ -153,7 +163,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
153
163
|
onChange={(next) => {
|
|
154
164
|
setSort(next);
|
|
155
165
|
}}
|
|
156
|
-
options={optionsFor(
|
|
166
|
+
options={optionsFor()}
|
|
157
167
|
/>
|
|
158
168
|
</Flex>
|
|
159
169
|
)}
|
package/src/default/index.ts
CHANGED
|
@@ -74,6 +74,7 @@ export {
|
|
|
74
74
|
CHIP_BAND_ORDER,
|
|
75
75
|
CHIP_ROW_CAP,
|
|
76
76
|
CHIP_ROW_CLASS,
|
|
77
|
+
CHIP_ROW_MIN_HEIGHT,
|
|
77
78
|
CHIP_ROW_STYLE_HREF,
|
|
78
79
|
appliedChipTestId,
|
|
79
80
|
buildAppliedChips,
|
|
@@ -103,6 +104,7 @@ export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
|
|
|
103
104
|
|
|
104
105
|
export {
|
|
105
106
|
FacetGroupControl,
|
|
107
|
+
facetGroupIsEmptyHeading,
|
|
106
108
|
facetGroupShape,
|
|
107
109
|
facetOptionNodes,
|
|
108
110
|
isDictionaryFacet,
|
|
@@ -122,8 +124,13 @@ export type {
|
|
|
122
124
|
export {
|
|
123
125
|
PopularValues,
|
|
124
126
|
popularOptions,
|
|
127
|
+
popularValuesLadderCss,
|
|
128
|
+
POPULAR_VALUES_CLASS,
|
|
125
129
|
POPULAR_VALUES_COLUMNS,
|
|
130
|
+
POPULAR_VALUES_LADDER,
|
|
126
131
|
POPULAR_VALUES_LIMIT,
|
|
132
|
+
POPULAR_VALUES_STYLE_HREF,
|
|
133
|
+
POPULAR_VALUE_COLUMN_WIDTH,
|
|
127
134
|
} from "./PopularValues.js";
|
|
128
135
|
export type { PopularValuesProps } from "./PopularValues.js";
|
|
129
136
|
export { PartitionChips } from "./PartitionChips.js";
|
|
@@ -166,7 +173,7 @@ export type {
|
|
|
166
173
|
} from "./DegradationNotice.js";
|
|
167
174
|
export { UrlIssueNotice } from "./UrlIssueNotice.js";
|
|
168
175
|
export { RangeFilterRow } from "./RangeFilterRow.js";
|
|
169
|
-
export type { RangeFilterRowProps } from "./RangeFilterRow.js";
|
|
176
|
+
export type { RangeDraft, RangeFilterRowProps } from "./RangeFilterRow.js";
|
|
170
177
|
|
|
171
178
|
// ── theming ─────────────────────────────────────────────────────────────────
|
|
172
179
|
export type { ThemeModeProp } from "./types.js";
|
|
@@ -127,6 +127,13 @@ export interface FacetPanelBag {
|
|
|
127
127
|
*/
|
|
128
128
|
setValues(slug: string, values: readonly string[]): void;
|
|
129
129
|
setRange(slug: string, range: SearchRange | null): void;
|
|
130
|
+
/**
|
|
131
|
+
* Write several ranges in ONE commit — see
|
|
132
|
+
* {@link SearchStateBag.setRanges}. What the panel's single "Apply"
|
|
133
|
+
* calls: `setRange` twice in a tick applies the second axis and drops the
|
|
134
|
+
* first, because both edits fold into the same starting state.
|
|
135
|
+
*/
|
|
136
|
+
setRanges(ranges: Readonly<Record<string, SearchRange | null>>): void;
|
|
130
137
|
clear(slug: string): void;
|
|
131
138
|
clearAll(): void;
|
|
132
139
|
}
|
|
@@ -206,8 +213,15 @@ export function useFacetPanel(props: {
|
|
|
206
213
|
*/
|
|
207
214
|
resolveFacetLabels?: FacetLabelResolver;
|
|
208
215
|
} = {}): FacetPanelBag {
|
|
209
|
-
const {
|
|
210
|
-
|
|
216
|
+
const {
|
|
217
|
+
state: searchState,
|
|
218
|
+
setFilter,
|
|
219
|
+
setRange,
|
|
220
|
+
setRanges,
|
|
221
|
+
clearAll,
|
|
222
|
+
toggleFilter,
|
|
223
|
+
activeFilters,
|
|
224
|
+
} = useSearchState();
|
|
211
225
|
const t = useT();
|
|
212
226
|
const query = useSearchQuery(
|
|
213
227
|
searchState,
|
|
@@ -285,6 +299,7 @@ export function useFacetPanel(props: {
|
|
|
285
299
|
toggle: toggleFilter,
|
|
286
300
|
setValues: setFilter,
|
|
287
301
|
setRange,
|
|
302
|
+
setRanges,
|
|
288
303
|
clear: (slug) => {
|
|
289
304
|
setFilter(slug, []);
|
|
290
305
|
},
|