@stapel/search-react 0.31.0 → 0.32.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 +82 -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 +16 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +94 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +22 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +31 -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 +55 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +78 -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 +240 -124
- package/src/default/FilterChips.tsx +39 -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 +158 -47
|
@@ -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
|
},
|
|
@@ -168,6 +168,21 @@ export interface SearchStateBag {
|
|
|
168
168
|
toggleFilter(slug: string, value: string): void;
|
|
169
169
|
setFilter(slug: string, values: readonly string[]): void;
|
|
170
170
|
setRange(slug: string, range: SearchRange | null): void;
|
|
171
|
+
/**
|
|
172
|
+
* Write SEVERAL ranges at once — the bulk half of {@link setRange}, and the
|
|
173
|
+
* same relationship `setFilter` has to `toggleFilter`.
|
|
174
|
+
*
|
|
175
|
+
* Every setter here closes over the state it edits, so two of them in one
|
|
176
|
+
* tick both fold their change into the SAME starting value and the second
|
|
177
|
+
* one wins. That is invisible while one control writes one axis; it is the
|
|
178
|
+
* whole story for a filter panel with one "Apply" over a price row and
|
|
179
|
+
* a mileage row, where two calls would have applied the mileage and quietly
|
|
180
|
+
* dropped the price. This folds them all into one state and commits once —
|
|
181
|
+
* one request, one history entry.
|
|
182
|
+
*
|
|
183
|
+
* `null` for a slug clears it, exactly as it does on {@link setRange}.
|
|
184
|
+
*/
|
|
185
|
+
setRanges(ranges: Readonly<Record<string, SearchRange | null>>): void;
|
|
171
186
|
setGeo(geo: SearchGeo | null): void;
|
|
172
187
|
setLimit(limit: number | null): void;
|
|
173
188
|
clearAll(): void;
|
|
@@ -506,6 +521,14 @@ export function SearchStateProvider(
|
|
|
506
521
|
apply(setFilterValues(state, slug, values), historyOptions("filter")),
|
|
507
522
|
setRange: (slug, range) =>
|
|
508
523
|
apply(setRangeValue(state, slug, range), historyOptions("range")),
|
|
524
|
+
setRanges: (ranges) =>
|
|
525
|
+
apply(
|
|
526
|
+
Object.entries(ranges).reduce(
|
|
527
|
+
(next, [slug, range]) => setRangeValue(next, slug, range),
|
|
528
|
+
state
|
|
529
|
+
),
|
|
530
|
+
historyOptions("range")
|
|
531
|
+
),
|
|
509
532
|
setGeo: (geo) => {
|
|
510
533
|
apply(patchSearchState(state, { geo }), historyOptions("geo"));
|
|
511
534
|
},
|
package/src/index.ts
CHANGED
|
@@ -49,16 +49,21 @@ export {
|
|
|
49
49
|
SUGGEST_DEGRADED_CATEGORIES,
|
|
50
50
|
SUGGEST_DEGRADED_ROLLUP,
|
|
51
51
|
suggestTerms,
|
|
52
|
+
withheldSlugs,
|
|
52
53
|
} from "./api/types.js";
|
|
53
54
|
export type {
|
|
55
|
+
FacetAxisKind,
|
|
54
56
|
FacetCategoryCount,
|
|
55
57
|
FacetLabels,
|
|
56
58
|
FacetLabelsMap,
|
|
57
59
|
FacetMeta,
|
|
60
|
+
FacetRangeAxis,
|
|
58
61
|
FacetRangeBounds,
|
|
59
62
|
FacetRangesMap,
|
|
60
63
|
FacetSelection,
|
|
64
|
+
FacetWithheldAxis,
|
|
61
65
|
FacetWithheldGroup,
|
|
66
|
+
FacetWithheldReason,
|
|
62
67
|
RankingResponse,
|
|
63
68
|
Scorer,
|
|
64
69
|
SearchDegradation,
|
|
@@ -156,6 +161,9 @@ export {
|
|
|
156
161
|
} from "./state/ranges.js";
|
|
157
162
|
export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
|
|
158
163
|
|
|
164
|
+
export { orderPanelItems } from "./state/panel.js";
|
|
165
|
+
export type { OrderPanelItemsInput, PanelItem } from "./state/panel.js";
|
|
166
|
+
|
|
159
167
|
export {
|
|
160
168
|
SEARCH_BOX_DEBOUNCE_MS,
|
|
161
169
|
SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
|
package/src/state/facets.ts
CHANGED
|
@@ -239,6 +239,19 @@ export interface FacetGroup {
|
|
|
239
239
|
* hold. `undefined` means nobody said, not "inline".
|
|
240
240
|
*/
|
|
241
241
|
readonly vocabulary?: string;
|
|
242
|
+
/**
|
|
243
|
+
* Where this group sits in the ONE sequence the panel draws — the answer's
|
|
244
|
+
* `facet_labels[<slug>].order`, numbered together with the numeric axes'
|
|
245
|
+
* `facet_meta.ranges[<slug>].order` (stapel-search 0.16.0+).
|
|
246
|
+
*
|
|
247
|
+
* The point of the field is that a group and a range are two ways of
|
|
248
|
+
* narrowing one authored feature, so they share a scale: drawn sorted by it,
|
|
249
|
+
* "Price" and "Year" land among the makes and models instead of above and
|
|
250
|
+
* below all of them. `undefined` (an older server, or a group the plan has
|
|
251
|
+
* no place for, which the wire says as `null`) means "no stated position",
|
|
252
|
+
* and `orderPanelItems` sorts those after everything that has one.
|
|
253
|
+
*/
|
|
254
|
+
readonly order?: number | undefined;
|
|
242
255
|
/** The group's heading: the answer's own `label`, else the feature's
|
|
243
256
|
* display name, else — with a dev warning — the raw slug. */
|
|
244
257
|
readonly label: string;
|
|
@@ -606,6 +619,19 @@ function optionalVocabulary(
|
|
|
606
619
|
return vocabulary === undefined ? {} : { vocabulary };
|
|
607
620
|
}
|
|
608
621
|
|
|
622
|
+
/**
|
|
623
|
+
* The group's stated place in the panel, when the answer states one.
|
|
624
|
+
*
|
|
625
|
+
* `null` on the wire is the server's own "the plan has no position for this
|
|
626
|
+
* group", which reads exactly like a server too old to state one: both mean
|
|
627
|
+
* "sort me after everything that does have a place". So both spread to
|
|
628
|
+
* nothing, and `FacetGroup.order` is a number or absent — never a `null` a
|
|
629
|
+
* comparator would have to remember to special-case.
|
|
630
|
+
*/
|
|
631
|
+
function optionalOrder(order: number | null | undefined): { order?: number } {
|
|
632
|
+
return typeof order === "number" ? { order } : {};
|
|
633
|
+
}
|
|
634
|
+
|
|
609
635
|
function resolveVocabulary(
|
|
610
636
|
input: BuildFacetGroupsInput,
|
|
611
637
|
feature: FeatureDef | undefined,
|
|
@@ -813,6 +839,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
813
839
|
return {
|
|
814
840
|
slug,
|
|
815
841
|
urlKey: keys.write[slug] ?? slug,
|
|
842
|
+
...optionalOrder(input.facetLabels?.[slug]?.order),
|
|
816
843
|
...optionalVocabulary(resolveVocabulary(input, feature, slug)),
|
|
817
844
|
...resolveGroupLabel(input, feature, slug),
|
|
818
845
|
feature,
|