@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
|
@@ -76,13 +76,22 @@ import {
|
|
|
76
76
|
Divider,
|
|
77
77
|
Flex,
|
|
78
78
|
Input,
|
|
79
|
+
Skeleton,
|
|
79
80
|
Typography,
|
|
80
81
|
theme,
|
|
81
82
|
} from "antd";
|
|
82
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
SlotPlaceholder,
|
|
85
|
+
actionAvailable,
|
|
86
|
+
actionBlocked,
|
|
87
|
+
useT,
|
|
88
|
+
useTPlural,
|
|
89
|
+
} from "@stapel/core";
|
|
90
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
83
91
|
import {
|
|
84
92
|
EmptyState,
|
|
85
93
|
ErrorAlert,
|
|
94
|
+
GatedButton,
|
|
86
95
|
LoadList,
|
|
87
96
|
SkinTheme,
|
|
88
97
|
} from "@stapel/tokens-antd/skin";
|
|
@@ -101,11 +110,15 @@ import {
|
|
|
101
110
|
orderFacetGroupsBySchema,
|
|
102
111
|
} from "../state/facets.js";
|
|
103
112
|
import type { FacetGroup } from "../state/facets.js";
|
|
104
|
-
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
113
|
+
import { FacetGroupControl, facetGroupIsEmptyHeading } from "./FacetGroupControl.js";
|
|
105
114
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
115
|
+
import type { RangeGroup } from "../state/ranges.js";
|
|
116
|
+
import { orderPanelItems } from "../state/panel.js";
|
|
117
|
+
import type { PanelItem } from "../state/panel.js";
|
|
106
118
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
107
119
|
import { LanguageSelect } from "./LanguageSelect.js";
|
|
108
120
|
import { RANGE_ROW_MIN_HEIGHT, RangeFilterRow, RangeRowSkeleton } from "./RangeFilterRow.js";
|
|
121
|
+
import type { RangeDraft } from "./RangeFilterRow.js";
|
|
109
122
|
import type { ThemeModeProp } from "./types.js";
|
|
110
123
|
|
|
111
124
|
/**
|
|
@@ -228,9 +241,36 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
228
241
|
* and the chip row cannot print two different words for one value.
|
|
229
242
|
*/
|
|
230
243
|
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
244
|
+
/**
|
|
245
|
+
* Draw BOTH a bucket list and a from/to picker for a slug the answer
|
|
246
|
+
* counted, instead of leaving the axis to its group.
|
|
247
|
+
*
|
|
248
|
+
* Default `false`, which is the fix: an imported `year` is a vocabulary
|
|
249
|
+
* choice in the catalogue and a measurement in the plan, and the rail used
|
|
250
|
+
* to draw a checkbox group of years and a from/to over the same field
|
|
251
|
+
* further down — two controls writing one filter, neither showing what the
|
|
252
|
+
* other did. See `BuildRangeGroupsInput.countedFacets`.
|
|
253
|
+
*/
|
|
254
|
+
readonly bothAxes?: boolean;
|
|
231
255
|
/** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
|
|
232
256
|
* to a path). Unfilled, an active category still gets a "clear" control. */
|
|
233
257
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
258
|
+
/**
|
|
259
|
+
* Draw the "Category" pane at all. Default `true`.
|
|
260
|
+
*
|
|
261
|
+
* `false` removes it ENTIRELY — no cascade, no development placeholder, and
|
|
262
|
+
* in particular no raw-id fallback: the arm that prints "Category: 32/149"
|
|
263
|
+
* with a "clear" button under it goes with the rest. That fallback exists so
|
|
264
|
+
* a shared link narrowing to a category always carries the control that
|
|
265
|
+
* widens it again, and it is the right default. It is the wrong thing on a
|
|
266
|
+
* surface whose category IS the page — a catalogue leaf reached by walking
|
|
267
|
+
* the tree, where the tree is the navigation above the panel and the id path
|
|
268
|
+
* inside the filters is machine state printed at a shopper.
|
|
269
|
+
*
|
|
270
|
+
* A host turning it off owns the way back out: on such a page it is the
|
|
271
|
+
* breadcrumb, or the tiles the reader arrived through.
|
|
272
|
+
*/
|
|
273
|
+
readonly categoryFilter?: boolean;
|
|
234
274
|
/** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
|
|
235
275
|
readonly languages?: readonly string[];
|
|
236
276
|
/**
|
|
@@ -545,6 +585,20 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
545
585
|
// panel's own search box: the URL is the search, and how much of the rail a
|
|
546
586
|
// person has unfolded is not part of it.
|
|
547
587
|
const [tailOpen, setTailOpen] = useState(false);
|
|
588
|
+
/**
|
|
589
|
+
* The range drafts the rows have reported and nobody has applied yet.
|
|
590
|
+
*
|
|
591
|
+
* The panel owns ONE "Apply" for every from/to row it draws — see
|
|
592
|
+
* `RangeFilterRow`'s module note — so it also owns what those rows have
|
|
593
|
+
* typed. Keyed by slug, emptied by an apply (the rows then re-read the URL
|
|
594
|
+
* they just wrote) and by a clear.
|
|
595
|
+
*/
|
|
596
|
+
const [rangeDrafts, setRangeDrafts] = useState<
|
|
597
|
+
Readonly<Record<string, RangeDraft>>
|
|
598
|
+
>({});
|
|
599
|
+
const noteDraft = (slug: string, draft: RangeDraft): void => {
|
|
600
|
+
setRangeDrafts((was) => ({ ...was, [slug]: draft }));
|
|
601
|
+
};
|
|
548
602
|
// `true` is the shape the prop shipped with and keeps meaning: pinned.
|
|
549
603
|
const footerBar: "sticky" | "static" | "none" =
|
|
550
604
|
props.footerBar === true
|
|
@@ -579,10 +633,21 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
579
633
|
? { categoryFeatures: props.categoryFeatures }
|
|
580
634
|
: {}),
|
|
581
635
|
coreRanges: bag.coreRanges,
|
|
582
|
-
// The measured ends, and the axes the
|
|
583
|
-
// a vocabulary-backed year is a from/to
|
|
584
|
-
// says it has numbers behind it
|
|
636
|
+
// The measured ends, the CAPTIONS (0.16.0), and the axes the
|
|
637
|
+
// schema types as choices — a vocabulary-backed year is a from/to
|
|
638
|
+
// here because the answer says it has numbers behind it
|
|
639
|
+
// (stapel-search 0.14.7).
|
|
585
640
|
...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
|
|
641
|
+
// And the axes this answer planned and declined to offer, so the
|
|
642
|
+
// schema cannot put back a sparse or unnameable row the server
|
|
643
|
+
// just removed (0.16.0).
|
|
644
|
+
withheld: bag.withheld,
|
|
645
|
+
// ONE AXIS, ONE CONTROL. A slug the answer COUNTED already has a
|
|
646
|
+
// bucket list on this rail; a from/to picker over the same field
|
|
647
|
+
// is a second control writing the same filter. See
|
|
648
|
+
// `BuildRangeGroupsInput.countedFacets`.
|
|
649
|
+
countedFacets: bag.counted,
|
|
650
|
+
...(props.bothAxes !== undefined ? { bothAxes: props.bothAxes } : {}),
|
|
586
651
|
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
587
652
|
t,
|
|
588
653
|
});
|
|
@@ -608,6 +673,67 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
608
673
|
props.partition !== undefined ||
|
|
609
674
|
coreRanges.length > 0 ||
|
|
610
675
|
state.geo !== undefined;
|
|
676
|
+
/**
|
|
677
|
+
* Apply every pending draft at once — the panel's own button.
|
|
678
|
+
*
|
|
679
|
+
* Only the DIRTY ones are sent: an Apply over three rows where one
|
|
680
|
+
* was typed into is one request, not three, and the two untouched
|
|
681
|
+
* rows must not rewrite the URL with the values it already carries.
|
|
682
|
+
* An unusable draft (`100..50`) blocks the button rather than being
|
|
683
|
+
* skipped quietly, which is the same refusal the row used to make
|
|
684
|
+
* for itself.
|
|
685
|
+
*/
|
|
686
|
+
const pending = Object.entries(rangeDrafts).filter(
|
|
687
|
+
([, draft]) => draft.dirty
|
|
688
|
+
);
|
|
689
|
+
const rangesUsable = pending.every(([, draft]) => draft.usable);
|
|
690
|
+
const applyRanges = (): void => {
|
|
691
|
+
if (!rangesUsable) return;
|
|
692
|
+
// ONE commit. `setRange` per axis would fold every edit into the
|
|
693
|
+
// same starting state and apply only the last of them — see
|
|
694
|
+
// `SearchStateBag.setRanges`.
|
|
695
|
+
bag.setRanges(Object.fromEntries(
|
|
696
|
+
pending.map(([slug, draft]) => [slug, draft.range])
|
|
697
|
+
));
|
|
698
|
+
setRangeDrafts({});
|
|
699
|
+
};
|
|
700
|
+
const rangesApply: ActionAvailability = rangesUsable
|
|
701
|
+
? actionAvailable()
|
|
702
|
+
: actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
|
|
703
|
+
/** One from/to row, reporting to the button above rather than
|
|
704
|
+
* carrying one of its own. */
|
|
705
|
+
const rangeRow = (group: RangeGroup): ReactElement => (
|
|
706
|
+
<RangeFilterRow
|
|
707
|
+
key={group.slug}
|
|
708
|
+
group={group}
|
|
709
|
+
onApply={bag.setRange}
|
|
710
|
+
onDraft={noteDraft}
|
|
711
|
+
onCommit={applyRanges}
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
/**
|
|
715
|
+
* The panel's single "Apply", under the rows it commits.
|
|
716
|
+
*
|
|
717
|
+
* Drawn only where there is a from/to row to commit, primary only
|
|
718
|
+
* once something is actually pending — a filled button over three
|
|
719
|
+
* untouched fields is a button that does nothing, and the rail used
|
|
720
|
+
* to draw one of those per row.
|
|
721
|
+
*/
|
|
722
|
+
const rangesApplyButton = (rows: readonly RangeGroup[]): ReactNode =>
|
|
723
|
+
rows.length === 0 ? null : (
|
|
724
|
+
<GatedButton
|
|
725
|
+
gate={rangesApply}
|
|
726
|
+
type={pending.length > 0 ? "primary" : "default"}
|
|
727
|
+
style={{ alignSelf: "flex-start" }}
|
|
728
|
+
testId="facet-ranges-apply"
|
|
729
|
+
data-pending={pending.length}
|
|
730
|
+
data-analytics="none"
|
|
731
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
732
|
+
onClick={applyRanges}
|
|
733
|
+
>
|
|
734
|
+
{t(SEARCH_I18N_KEYS.facetsRangeApply)}
|
|
735
|
+
</GatedButton>
|
|
736
|
+
);
|
|
611
737
|
return (
|
|
612
738
|
<Flex vertical gap={spacing[3]} data-testid="search-facets">
|
|
613
739
|
{/* In a 280px rail this row laid the word "Filters" out in a
|
|
@@ -654,40 +780,21 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
654
780
|
<div data-testid="search-partition">{props.partition}</div>
|
|
655
781
|
)}
|
|
656
782
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
783
|
+
{/* The "Category" pane, unless the surface has said its category
|
|
784
|
+
is the page rather than a filter over it — see
|
|
785
|
+
`FacetPanelPaneProps.categoryFilter`. Off means OFF: no
|
|
786
|
+
placeholder, and no raw-id line either. */}
|
|
787
|
+
{props.categoryFilter !== false && (
|
|
788
|
+
<CategoryFilter
|
|
789
|
+
{...(props.renderCategoryFilter !== undefined
|
|
790
|
+
? { render: props.renderCategoryFilter }
|
|
791
|
+
: {})}
|
|
792
|
+
/>
|
|
793
|
+
)}
|
|
662
794
|
<LanguageSelect
|
|
663
795
|
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
664
796
|
/>
|
|
665
797
|
|
|
666
|
-
{/* Price, and only the CORE axes — the ones the server declares
|
|
667
|
-
for every document in every category.
|
|
668
|
-
|
|
669
|
-
Every other numeric row is an ATTRIBUTE the category happens
|
|
670
|
-
to declare, and on the deployed phones leaf all seven were
|
|
671
|
-
parcel dimensions and wholesale packing. They used to render
|
|
672
|
-
here, immediately under the price, which put 908px of
|
|
673
|
-
shipping-weight and packing-count rows between the buyer and
|
|
674
|
-
the brand (D120/D121).
|
|
675
|
-
They now render AFTER the facets, which is the band order the
|
|
676
|
-
chip row has used since D16 — see `CHIP_BAND_ORDER`. */}
|
|
677
|
-
{coreRanges.length > 0 && (
|
|
678
|
-
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
679
|
-
{coreRanges.map((group) => (
|
|
680
|
-
<RangeFilterRow
|
|
681
|
-
key={group.slug}
|
|
682
|
-
group={group}
|
|
683
|
-
onApply={bag.setRange}
|
|
684
|
-
/>
|
|
685
|
-
))}
|
|
686
|
-
</Flex>
|
|
687
|
-
)}
|
|
688
|
-
|
|
689
|
-
{coreRanges.length > 0 && <Divider style={{ margin: 0 }} />}
|
|
690
|
-
|
|
691
798
|
{/* Honesty flags, not failures: the counts ARE approximate and
|
|
692
799
|
those slugs WERE skipped, and a red box would teach a person
|
|
693
800
|
the page is broken. `ErrorAlert` is for a read that did not
|
|
@@ -711,10 +818,68 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
711
818
|
/>
|
|
712
819
|
)}
|
|
713
820
|
|
|
821
|
+
{/* NO GROUPS, BUT STILL A PANEL. `LoadList` draws its `empty` arm
|
|
822
|
+
instead of the children, so the from/to rows would vanish with
|
|
823
|
+
the checkboxes on a leaf whose only filter is a price — the one
|
|
824
|
+
shape where the rail is nothing else. Drawn here, in the place
|
|
825
|
+
the merged list would have put them. */}
|
|
826
|
+
{bag.state.status === "ready" &&
|
|
827
|
+
bag.state.data.length === 0 &&
|
|
828
|
+
ranges.length > 0 && (
|
|
829
|
+
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
830
|
+
{ranges.map(rangeRow)}
|
|
831
|
+
{rangesApplyButton(ranges)}
|
|
832
|
+
</Flex>
|
|
833
|
+
)}
|
|
834
|
+
|
|
835
|
+
{/* ONE BOX WHILE THE PLAN IS UNKNOWN (0.34 CLS on a live host).
|
|
836
|
+
The numeric band and its reservation used to be SIBLINGS of
|
|
837
|
+
this arm: the rail drew four skeleton rows, then a divider,
|
|
838
|
+
then N range skeletons under it, and when the answer landed the
|
|
839
|
+
rows moved up into the order the plan asked for — every one of
|
|
840
|
+
them travelling past the reader's eye. The loading arm now
|
|
841
|
+
covers the whole pane, so nothing renders after it until there
|
|
842
|
+
is an order to render in.
|
|
843
|
+
|
|
844
|
+
Two things are unknown at first paint and each gets its own
|
|
845
|
+
reserve inside the box:
|
|
846
|
+
- the SCHEMA (`categoryFeatures` undefined) — the axis count
|
|
847
|
+
is unknown, so one row's floor is the guess;
|
|
848
|
+
- the ANSWER with a known schema — the count is certain from
|
|
849
|
+
the schema (and, better, from what an earlier answer FOR
|
|
850
|
+
THIS CATEGORY measured: `bag.reservedRangeAxes`), so that
|
|
851
|
+
many skeleton rows, each `RANGE_ROW_MIN_HEIGHT` tall like
|
|
852
|
+
the row it will become. */}
|
|
714
853
|
<LoadList
|
|
715
854
|
state={bag.state}
|
|
716
855
|
testId="facets"
|
|
717
856
|
skeletonRows={4}
|
|
857
|
+
loading={
|
|
858
|
+
<Flex vertical gap={spacing[3]}>
|
|
859
|
+
<Skeleton active paragraph={{ rows: 4 }} />
|
|
860
|
+
<Divider style={{ margin: 0 }} />
|
|
861
|
+
{props.categoryFeatures === undefined ? (
|
|
862
|
+
<div
|
|
863
|
+
aria-hidden="true"
|
|
864
|
+
data-testid="search-ranges-attributes-reserve"
|
|
865
|
+
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
866
|
+
/>
|
|
867
|
+
) : (
|
|
868
|
+
<Flex
|
|
869
|
+
vertical
|
|
870
|
+
gap={spacing[3]}
|
|
871
|
+
data-testid="search-ranges-attributes"
|
|
872
|
+
>
|
|
873
|
+
{Array.from(
|
|
874
|
+
{ length: Math.max(reservedAxes, 1) },
|
|
875
|
+
(_, index) => (
|
|
876
|
+
<RangeRowSkeleton key={index} />
|
|
877
|
+
)
|
|
878
|
+
)}
|
|
879
|
+
</Flex>
|
|
880
|
+
)}
|
|
881
|
+
</Flex>
|
|
882
|
+
}
|
|
718
883
|
empty={<FacetsEmptyArm bag={bag} hasOtherDrawable={hasOtherDrawable} />}
|
|
719
884
|
failed={(error) => (
|
|
720
885
|
<ErrorAlert
|
|
@@ -740,7 +905,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
740
905
|
// narrows a catalogue in. Groups the schema does not name
|
|
741
906
|
// keep evidence order among themselves.
|
|
742
907
|
const drawable = orderFacetGroupsBySchema({
|
|
743
|
-
groups: groups
|
|
908
|
+
groups: groups
|
|
909
|
+
.filter(facetGroupIsDrawable)
|
|
910
|
+
// A bucket list with no buckets is a caption and an empty
|
|
911
|
+
// box. Dropped HERE and not only inside the control, so
|
|
912
|
+
// the count in "All filters (K)", the panel search and the
|
|
913
|
+
// fold all agree about what is on the rail.
|
|
914
|
+
.filter((group) => !facetGroupIsEmptyHeading(group)),
|
|
744
915
|
...(props.categoryFeatures !== undefined
|
|
745
916
|
? { categoryFeatures: props.categoryFeatures }
|
|
746
917
|
: {}),
|
|
@@ -771,6 +942,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
771
942
|
);
|
|
772
943
|
const listed =
|
|
773
944
|
needle === "" ? drawable : drawable.filter(matches);
|
|
945
|
+
// The panel search narrows the from/to rows too: a person who
|
|
946
|
+
// typed "price" is looking for the price picker as much as for
|
|
947
|
+
// a checkbox group, and leaving the rows in would answer the
|
|
948
|
+
// query with everything it did not match.
|
|
949
|
+
const listedRanges =
|
|
950
|
+
needle === ""
|
|
951
|
+
? ranges
|
|
952
|
+
: ranges.filter((group) =>
|
|
953
|
+
group.label.toLowerCase().includes(needle)
|
|
954
|
+
);
|
|
774
955
|
// The tail. Only while nothing is typed: a query has already
|
|
775
956
|
// narrowed the list, and folding its answer would hide the
|
|
776
957
|
// thing that was looked for. One group over the limit is not
|
|
@@ -828,29 +1009,52 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
828
1009
|
not decide — `facetGroupShape` reads the same config keys
|
|
829
1010
|
the attributes editor reads, so a facet cannot look one
|
|
830
1011
|
way here and another way in the composer. */}
|
|
1012
|
+
{/* ONE PANEL, ONE ORDER. The bucket lists and the from/to
|
|
1013
|
+
pickers are numbered on the same scale by the plan
|
|
1014
|
+
(stapel-search 0.16.0), so they are drawn as one
|
|
1015
|
+
sequence: "Price" and "Year" land where the category
|
|
1016
|
+
authored them instead of above and below every choice.
|
|
1017
|
+
A host's `pinnedFacets` reaches both halves — see
|
|
1018
|
+
`orderPanelItems`, which also holds the band order used
|
|
1019
|
+
when nobody numbered anything. */}
|
|
831
1020
|
{/* The key changes with the query's presence ON PURPOSE:
|
|
832
1021
|
`defaultOpen` is an initial value, and a group the
|
|
833
1022
|
panel-search matched has to render OPEN — a hit behind
|
|
834
1023
|
a closed header is not an answer. Remounting is the
|
|
835
1024
|
honest way to re-ask the question; the person's own
|
|
836
1025
|
opens and closes come back when the box clears. */}
|
|
837
|
-
{
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1026
|
+
{orderPanelItems({
|
|
1027
|
+
groups: shownGroups,
|
|
1028
|
+
ranges: listedRanges,
|
|
1029
|
+
...(props.pinnedFacets !== undefined
|
|
1030
|
+
? { pinned: props.pinnedFacets }
|
|
1031
|
+
: {}),
|
|
1032
|
+
}).map((item: PanelItem) =>
|
|
1033
|
+
item.kind === "range" ? (
|
|
1034
|
+
rangeRow(item.range)
|
|
1035
|
+
) : (
|
|
1036
|
+
<FacetGroupControl
|
|
1037
|
+
key={
|
|
1038
|
+
needle === ""
|
|
1039
|
+
? item.group.slug
|
|
1040
|
+
: `${item.group.slug}:match`
|
|
1041
|
+
}
|
|
1042
|
+
group={item.group}
|
|
1043
|
+
onToggle={bag.toggle}
|
|
1044
|
+
onSetValues={bag.setValues}
|
|
1045
|
+
collapsible
|
|
1046
|
+
defaultOpen={
|
|
1047
|
+
needle !== "" ||
|
|
1048
|
+
item.group.selected.length > 0 ||
|
|
1049
|
+
openByOrder.has(item.group.slug)
|
|
1050
|
+
}
|
|
1051
|
+
{...(props.dictionaryMode !== undefined
|
|
1052
|
+
? { dictionaryMode: props.dictionaryMode }
|
|
1053
|
+
: {})}
|
|
1054
|
+
/>
|
|
1055
|
+
)
|
|
1056
|
+
)}
|
|
1057
|
+
{rangesApplyButton(listedRanges)}
|
|
854
1058
|
{tailFolded && (
|
|
855
1059
|
<Button
|
|
856
1060
|
style={{ alignSelf: "flex-start" }}
|
|
@@ -876,77 +1080,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
876
1080
|
}}
|
|
877
1081
|
</LoadList>
|
|
878
1082
|
|
|
879
|
-
{/* The numeric tail, below the axes people actually narrow by.
|
|
880
|
-
On the deployed mobile-phones leaf this block is 908px of
|
|
881
|
-
battery health, four parcel dimensions and two wholesale
|
|
882
|
-
packing counts — and while it
|
|
883
|
-
rendered directly under the price it was the ONLY thing a
|
|
884
|
-
buyer could see in a viewport-tall rail (D120/D121, D74 on the
|
|
885
|
-
phone). It is a real filter for the person who wants it, so it
|
|
886
|
-
is not deleted; it is ranked where the chip row already ranks
|
|
887
|
-
it. */}
|
|
888
|
-
{/* The reservation, not just the rows (D361).
|
|
889
|
-
On a live category feed at 1536px this block's arrival was a
|
|
890
|
-
53px jump: `attributeRanges` draws from the CATEGORY SCHEMA
|
|
891
|
-
(`props.categoryFeatures`), and a host that fetches the
|
|
892
|
-
schema alongside the search answer had nothing here at all
|
|
893
|
-
until both landed — no host slot reserved the box, so the
|
|
894
|
-
rail grew under the reader's eye the instant it did.
|
|
895
|
-
|
|
896
|
-
Two things can be unknown at first paint, and each gets its
|
|
897
|
-
own reservation:
|
|
898
|
-
- the SCHEMA itself (`categoryFeatures` undefined) — the
|
|
899
|
-
axis count is unknown, so the fallback is one row's floor,
|
|
900
|
-
a guess rather than nothing;
|
|
901
|
-
- the ANSWER (`bag.state` not yet "ready") with a known
|
|
902
|
-
schema — the axis COUNT is already certain from the
|
|
903
|
-
schema, so the rail draws that many skeleton rows, each
|
|
904
|
-
`RANGE_ROW_MIN_HEIGHT` tall like the real one it will
|
|
905
|
-
become. Same count in both arms, so the swap from
|
|
906
|
-
skeleton to `<RangeFilterRow>` costs no further height.
|
|
907
|
-
|
|
908
|
-
And the schema is only the FIRST guess at that count. Since
|
|
909
|
-
stapel-search 0.14.7 the answer measures the axes that have
|
|
910
|
-
numbers behind them — including the ones the catalogue types
|
|
911
|
-
as choices, a vocabulary-backed year — so a leaf whose schema
|
|
912
|
-
declares two can answer with four. `bag.reservedRangeAxes` is
|
|
913
|
-
what an earlier answer FOR THIS CATEGORY reported, remembered
|
|
914
|
-
in the state provider; when there is one it sizes the block,
|
|
915
|
-
because it is the count the swap will actually land on. */}
|
|
916
|
-
{props.categoryFeatures === undefined ? (
|
|
917
|
-
<>
|
|
918
|
-
<Divider style={{ margin: 0 }} />
|
|
919
|
-
<div
|
|
920
|
-
aria-hidden="true"
|
|
921
|
-
data-testid="search-ranges-attributes-reserve"
|
|
922
|
-
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
923
|
-
/>
|
|
924
|
-
</>
|
|
925
|
-
) : (
|
|
926
|
-
(attributeRanges.length > 0 || reservedAxes > 0) && (
|
|
927
|
-
<>
|
|
928
|
-
<Divider style={{ margin: 0 }} />
|
|
929
|
-
<Flex
|
|
930
|
-
vertical
|
|
931
|
-
gap={spacing[3]}
|
|
932
|
-
data-testid="search-ranges-attributes"
|
|
933
|
-
>
|
|
934
|
-
{bag.state.status === "ready"
|
|
935
|
-
? attributeRanges.map((group) => (
|
|
936
|
-
<RangeFilterRow
|
|
937
|
-
key={group.slug}
|
|
938
|
-
group={group}
|
|
939
|
-
onApply={bag.setRange}
|
|
940
|
-
/>
|
|
941
|
-
))
|
|
942
|
-
: Array.from({ length: reservedAxes }, (_, index) => (
|
|
943
|
-
<RangeRowSkeleton key={index} />
|
|
944
|
-
))}
|
|
945
|
-
</Flex>
|
|
946
|
-
</>
|
|
947
|
-
)
|
|
948
|
-
)}
|
|
949
|
-
|
|
950
1083
|
{footerBar !== "none" && (
|
|
951
1084
|
<RailFooterBar
|
|
952
1085
|
activeFilters={bag.activeFilters}
|
|
@@ -307,6 +307,29 @@ export function orderChipFilters(
|
|
|
307
307
|
*/
|
|
308
308
|
export const CHIP_ROW_CAP = 8;
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The row's own block-size — one chip plus the row's padding, reserved from
|
|
312
|
+
* the FIRST frame wherever a row is expected.
|
|
313
|
+
*
|
|
314
|
+
* Measured on the host's phone SERP (`after-avtomobili-390-light`, 2026-09-05):
|
|
315
|
+
* the results block dropped ~68px the moment the chip row settled, an
|
|
316
|
+
* intermittent 0.045 CLS on a leaf. The row renders nothing until the answer
|
|
317
|
+
* lands — nothing is a correct thing to render for a search that will have no
|
|
318
|
+
* chips — and then it appears and pushes the first card down the page under
|
|
319
|
+
* the reader's eye.
|
|
320
|
+
*
|
|
321
|
+
* So the row reserves its box while the answer is IN FLIGHT AND a row is
|
|
322
|
+
* predictable: the surface handed a category schema, or the address names a
|
|
323
|
+
* category, both of which mean the plan will come back with axes. A bare text
|
|
324
|
+
* query with no category reserves nothing, because for that search the honest
|
|
325
|
+
* answer really is "no row" and reserving would be the same shift in the
|
|
326
|
+
* opposite direction.
|
|
327
|
+
*
|
|
328
|
+
* 44 is the phone control floor the chips themselves are sized to, plus the
|
|
329
|
+
* row's two `spacing[1]` bands of focus-ring room.
|
|
330
|
+
*/
|
|
331
|
+
export const CHIP_ROW_MIN_HEIGHT: number = 44 + spacing[1] * 2;
|
|
332
|
+
|
|
310
333
|
/**
|
|
311
334
|
* The visible row and what the door owes: the first `max` specs — and EVERY
|
|
312
335
|
* applied one, however many, because a constraint on screen must keep the
|
|
@@ -342,6 +365,14 @@ export interface FilterChipsCommonProps {
|
|
|
342
365
|
* value cannot read one way on a chip and another way inside the sheet.
|
|
343
366
|
*/
|
|
344
367
|
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
368
|
+
/**
|
|
369
|
+
* Draw a range chip for a slug that already has a bucket-list chip.
|
|
370
|
+
*
|
|
371
|
+
* Default `false`, and the same prop the panel takes — the chip row and the
|
|
372
|
+
* panel must not disagree about which axes exist. See
|
|
373
|
+
* `BuildRangeGroupsInput.countedFacets`.
|
|
374
|
+
*/
|
|
375
|
+
readonly bothAxes?: boolean;
|
|
345
376
|
}
|
|
346
377
|
|
|
347
378
|
export interface FilterChipsOpenerProps extends FilterChipsCommonProps {
|
|
@@ -473,6 +504,10 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
|
|
|
473
504
|
: {}),
|
|
474
505
|
coreRanges: bag.coreRanges,
|
|
475
506
|
...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
|
|
507
|
+
// One axis, one chip: a counted slug is already a bucket-list chip in
|
|
508
|
+
// this row, and a from/to chip beside it writes the same filter twice.
|
|
509
|
+
countedFacets: bag.counted,
|
|
510
|
+
...(props.bothAxes !== undefined ? { bothAxes: props.bothAxes } : {}),
|
|
476
511
|
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
477
512
|
t,
|
|
478
513
|
});
|
|
@@ -559,7 +594,22 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
|
|
|
559
594
|
* keeps its own door.
|
|
560
595
|
*/
|
|
561
596
|
const hasChips = showCategoryChip || ordered.length > 0;
|
|
562
|
-
if (!hasChips)
|
|
597
|
+
if (!hasChips) {
|
|
598
|
+
// THE BOX THE ROW WILL ARRIVE INTO — see `CHIP_ROW_MIN_HEIGHT`. Only
|
|
599
|
+
// while the answer is still in flight and this search is one a row is
|
|
600
|
+
// predictable for; a settled answer with no chips renders nothing, which
|
|
601
|
+
// is what it has always done and what it should do.
|
|
602
|
+
const expected =
|
|
603
|
+
bag.state.status === "loading" &&
|
|
604
|
+
((props.categoryFeatures?.length ?? 0) > 0 || state.category !== undefined);
|
|
605
|
+
return expected ? (
|
|
606
|
+
<div
|
|
607
|
+
aria-hidden="true"
|
|
608
|
+
data-testid="search-filter-chips-reserve"
|
|
609
|
+
style={{ minBlockSize: CHIP_ROW_MIN_HEIGHT }}
|
|
610
|
+
/>
|
|
611
|
+
) : null;
|
|
612
|
+
}
|
|
563
613
|
|
|
564
614
|
return (
|
|
565
615
|
<>
|