@stapel/search-react 0.30.1 → 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 +97 -0
- package/dist/api/generated/schema.d.ts +71 -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 +71 -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
|
@@ -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
|
/**
|
|
@@ -231,6 +244,22 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
231
244
|
/** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
|
|
232
245
|
* to a path). Unfilled, an active category still gets a "clear" control. */
|
|
233
246
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
247
|
+
/**
|
|
248
|
+
* Draw the "Category" pane at all. Default `true`.
|
|
249
|
+
*
|
|
250
|
+
* `false` removes it ENTIRELY — no cascade, no development placeholder, and
|
|
251
|
+
* in particular no raw-id fallback: the arm that prints "Category: 32/149"
|
|
252
|
+
* with a "clear" button under it goes with the rest. That fallback exists so
|
|
253
|
+
* a shared link narrowing to a category always carries the control that
|
|
254
|
+
* widens it again, and it is the right default. It is the wrong thing on a
|
|
255
|
+
* surface whose category IS the page — a catalogue leaf reached by walking
|
|
256
|
+
* the tree, where the tree is the navigation above the panel and the id path
|
|
257
|
+
* inside the filters is machine state printed at a shopper.
|
|
258
|
+
*
|
|
259
|
+
* A host turning it off owns the way back out: on such a page it is the
|
|
260
|
+
* breadcrumb, or the tiles the reader arrived through.
|
|
261
|
+
*/
|
|
262
|
+
readonly categoryFilter?: boolean;
|
|
234
263
|
/** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
|
|
235
264
|
readonly languages?: readonly string[];
|
|
236
265
|
/**
|
|
@@ -545,6 +574,20 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
545
574
|
// panel's own search box: the URL is the search, and how much of the rail a
|
|
546
575
|
// person has unfolded is not part of it.
|
|
547
576
|
const [tailOpen, setTailOpen] = useState(false);
|
|
577
|
+
/**
|
|
578
|
+
* The range drafts the rows have reported and nobody has applied yet.
|
|
579
|
+
*
|
|
580
|
+
* The panel owns ONE "Apply" for every from/to row it draws — see
|
|
581
|
+
* `RangeFilterRow`'s module note — so it also owns what those rows have
|
|
582
|
+
* typed. Keyed by slug, emptied by an apply (the rows then re-read the URL
|
|
583
|
+
* they just wrote) and by a clear.
|
|
584
|
+
*/
|
|
585
|
+
const [rangeDrafts, setRangeDrafts] = useState<
|
|
586
|
+
Readonly<Record<string, RangeDraft>>
|
|
587
|
+
>({});
|
|
588
|
+
const noteDraft = (slug: string, draft: RangeDraft): void => {
|
|
589
|
+
setRangeDrafts((was) => ({ ...was, [slug]: draft }));
|
|
590
|
+
};
|
|
548
591
|
// `true` is the shape the prop shipped with and keeps meaning: pinned.
|
|
549
592
|
const footerBar: "sticky" | "static" | "none" =
|
|
550
593
|
props.footerBar === true
|
|
@@ -579,10 +622,15 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
579
622
|
? { categoryFeatures: props.categoryFeatures }
|
|
580
623
|
: {}),
|
|
581
624
|
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
|
|
625
|
+
// The measured ends, the CAPTIONS (0.16.0), and the axes the
|
|
626
|
+
// schema types as choices — a vocabulary-backed year is a from/to
|
|
627
|
+
// here because the answer says it has numbers behind it
|
|
628
|
+
// (stapel-search 0.14.7).
|
|
585
629
|
...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
|
|
630
|
+
// And the axes this answer planned and declined to offer, so the
|
|
631
|
+
// schema cannot put back a sparse or unnameable row the server
|
|
632
|
+
// just removed (0.16.0).
|
|
633
|
+
withheld: bag.withheld,
|
|
586
634
|
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
587
635
|
t,
|
|
588
636
|
});
|
|
@@ -608,6 +656,67 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
608
656
|
props.partition !== undefined ||
|
|
609
657
|
coreRanges.length > 0 ||
|
|
610
658
|
state.geo !== undefined;
|
|
659
|
+
/**
|
|
660
|
+
* Apply every pending draft at once — the panel's own button.
|
|
661
|
+
*
|
|
662
|
+
* Only the DIRTY ones are sent: an Apply over three rows where one
|
|
663
|
+
* was typed into is one request, not three, and the two untouched
|
|
664
|
+
* rows must not rewrite the URL with the values it already carries.
|
|
665
|
+
* An unusable draft (`100..50`) blocks the button rather than being
|
|
666
|
+
* skipped quietly, which is the same refusal the row used to make
|
|
667
|
+
* for itself.
|
|
668
|
+
*/
|
|
669
|
+
const pending = Object.entries(rangeDrafts).filter(
|
|
670
|
+
([, draft]) => draft.dirty
|
|
671
|
+
);
|
|
672
|
+
const rangesUsable = pending.every(([, draft]) => draft.usable);
|
|
673
|
+
const applyRanges = (): void => {
|
|
674
|
+
if (!rangesUsable) return;
|
|
675
|
+
// ONE commit. `setRange` per axis would fold every edit into the
|
|
676
|
+
// same starting state and apply only the last of them — see
|
|
677
|
+
// `SearchStateBag.setRanges`.
|
|
678
|
+
bag.setRanges(Object.fromEntries(
|
|
679
|
+
pending.map(([slug, draft]) => [slug, draft.range])
|
|
680
|
+
));
|
|
681
|
+
setRangeDrafts({});
|
|
682
|
+
};
|
|
683
|
+
const rangesApply: ActionAvailability = rangesUsable
|
|
684
|
+
? actionAvailable()
|
|
685
|
+
: actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
|
|
686
|
+
/** One from/to row, reporting to the button above rather than
|
|
687
|
+
* carrying one of its own. */
|
|
688
|
+
const rangeRow = (group: RangeGroup): ReactElement => (
|
|
689
|
+
<RangeFilterRow
|
|
690
|
+
key={group.slug}
|
|
691
|
+
group={group}
|
|
692
|
+
onApply={bag.setRange}
|
|
693
|
+
onDraft={noteDraft}
|
|
694
|
+
onCommit={applyRanges}
|
|
695
|
+
/>
|
|
696
|
+
);
|
|
697
|
+
/**
|
|
698
|
+
* The panel's single "Apply", under the rows it commits.
|
|
699
|
+
*
|
|
700
|
+
* Drawn only where there is a from/to row to commit, primary only
|
|
701
|
+
* once something is actually pending — a filled button over three
|
|
702
|
+
* untouched fields is a button that does nothing, and the rail used
|
|
703
|
+
* to draw one of those per row.
|
|
704
|
+
*/
|
|
705
|
+
const rangesApplyButton = (rows: readonly RangeGroup[]): ReactNode =>
|
|
706
|
+
rows.length === 0 ? null : (
|
|
707
|
+
<GatedButton
|
|
708
|
+
gate={rangesApply}
|
|
709
|
+
type={pending.length > 0 ? "primary" : "default"}
|
|
710
|
+
style={{ alignSelf: "flex-start" }}
|
|
711
|
+
testId="facet-ranges-apply"
|
|
712
|
+
data-pending={pending.length}
|
|
713
|
+
data-analytics="none"
|
|
714
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
715
|
+
onClick={applyRanges}
|
|
716
|
+
>
|
|
717
|
+
{t(SEARCH_I18N_KEYS.facetsRangeApply)}
|
|
718
|
+
</GatedButton>
|
|
719
|
+
);
|
|
611
720
|
return (
|
|
612
721
|
<Flex vertical gap={spacing[3]} data-testid="search-facets">
|
|
613
722
|
{/* In a 280px rail this row laid the word "Filters" out in a
|
|
@@ -654,40 +763,21 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
654
763
|
<div data-testid="search-partition">{props.partition}</div>
|
|
655
764
|
)}
|
|
656
765
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
766
|
+
{/* The "Category" pane, unless the surface has said its category
|
|
767
|
+
is the page rather than a filter over it — see
|
|
768
|
+
`FacetPanelPaneProps.categoryFilter`. Off means OFF: no
|
|
769
|
+
placeholder, and no raw-id line either. */}
|
|
770
|
+
{props.categoryFilter !== false && (
|
|
771
|
+
<CategoryFilter
|
|
772
|
+
{...(props.renderCategoryFilter !== undefined
|
|
773
|
+
? { render: props.renderCategoryFilter }
|
|
774
|
+
: {})}
|
|
775
|
+
/>
|
|
776
|
+
)}
|
|
662
777
|
<LanguageSelect
|
|
663
778
|
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
664
779
|
/>
|
|
665
780
|
|
|
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
781
|
{/* Honesty flags, not failures: the counts ARE approximate and
|
|
692
782
|
those slugs WERE skipped, and a red box would teach a person
|
|
693
783
|
the page is broken. `ErrorAlert` is for a read that did not
|
|
@@ -711,10 +801,68 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
711
801
|
/>
|
|
712
802
|
)}
|
|
713
803
|
|
|
804
|
+
{/* NO GROUPS, BUT STILL A PANEL. `LoadList` draws its `empty` arm
|
|
805
|
+
instead of the children, so the from/to rows would vanish with
|
|
806
|
+
the checkboxes on a leaf whose only filter is a price — the one
|
|
807
|
+
shape where the rail is nothing else. Drawn here, in the place
|
|
808
|
+
the merged list would have put them. */}
|
|
809
|
+
{bag.state.status === "ready" &&
|
|
810
|
+
bag.state.data.length === 0 &&
|
|
811
|
+
ranges.length > 0 && (
|
|
812
|
+
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
813
|
+
{ranges.map(rangeRow)}
|
|
814
|
+
{rangesApplyButton(ranges)}
|
|
815
|
+
</Flex>
|
|
816
|
+
)}
|
|
817
|
+
|
|
818
|
+
{/* ONE BOX WHILE THE PLAN IS UNKNOWN (0.34 CLS on a live host).
|
|
819
|
+
The numeric band and its reservation used to be SIBLINGS of
|
|
820
|
+
this arm: the rail drew four skeleton rows, then a divider,
|
|
821
|
+
then N range skeletons under it, and when the answer landed the
|
|
822
|
+
rows moved up into the order the plan asked for — every one of
|
|
823
|
+
them travelling past the reader's eye. The loading arm now
|
|
824
|
+
covers the whole pane, so nothing renders after it until there
|
|
825
|
+
is an order to render in.
|
|
826
|
+
|
|
827
|
+
Two things are unknown at first paint and each gets its own
|
|
828
|
+
reserve inside the box:
|
|
829
|
+
- the SCHEMA (`categoryFeatures` undefined) — the axis count
|
|
830
|
+
is unknown, so one row's floor is the guess;
|
|
831
|
+
- the ANSWER with a known schema — the count is certain from
|
|
832
|
+
the schema (and, better, from what an earlier answer FOR
|
|
833
|
+
THIS CATEGORY measured: `bag.reservedRangeAxes`), so that
|
|
834
|
+
many skeleton rows, each `RANGE_ROW_MIN_HEIGHT` tall like
|
|
835
|
+
the row it will become. */}
|
|
714
836
|
<LoadList
|
|
715
837
|
state={bag.state}
|
|
716
838
|
testId="facets"
|
|
717
839
|
skeletonRows={4}
|
|
840
|
+
loading={
|
|
841
|
+
<Flex vertical gap={spacing[3]}>
|
|
842
|
+
<Skeleton active paragraph={{ rows: 4 }} />
|
|
843
|
+
<Divider style={{ margin: 0 }} />
|
|
844
|
+
{props.categoryFeatures === undefined ? (
|
|
845
|
+
<div
|
|
846
|
+
aria-hidden="true"
|
|
847
|
+
data-testid="search-ranges-attributes-reserve"
|
|
848
|
+
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
849
|
+
/>
|
|
850
|
+
) : (
|
|
851
|
+
<Flex
|
|
852
|
+
vertical
|
|
853
|
+
gap={spacing[3]}
|
|
854
|
+
data-testid="search-ranges-attributes"
|
|
855
|
+
>
|
|
856
|
+
{Array.from(
|
|
857
|
+
{ length: Math.max(reservedAxes, 1) },
|
|
858
|
+
(_, index) => (
|
|
859
|
+
<RangeRowSkeleton key={index} />
|
|
860
|
+
)
|
|
861
|
+
)}
|
|
862
|
+
</Flex>
|
|
863
|
+
)}
|
|
864
|
+
</Flex>
|
|
865
|
+
}
|
|
718
866
|
empty={<FacetsEmptyArm bag={bag} hasOtherDrawable={hasOtherDrawable} />}
|
|
719
867
|
failed={(error) => (
|
|
720
868
|
<ErrorAlert
|
|
@@ -740,7 +888,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
740
888
|
// narrows a catalogue in. Groups the schema does not name
|
|
741
889
|
// keep evidence order among themselves.
|
|
742
890
|
const drawable = orderFacetGroupsBySchema({
|
|
743
|
-
groups: groups
|
|
891
|
+
groups: groups
|
|
892
|
+
.filter(facetGroupIsDrawable)
|
|
893
|
+
// A bucket list with no buckets is a caption and an empty
|
|
894
|
+
// box. Dropped HERE and not only inside the control, so
|
|
895
|
+
// the count in "All filters (K)", the panel search and the
|
|
896
|
+
// fold all agree about what is on the rail.
|
|
897
|
+
.filter((group) => !facetGroupIsEmptyHeading(group)),
|
|
744
898
|
...(props.categoryFeatures !== undefined
|
|
745
899
|
? { categoryFeatures: props.categoryFeatures }
|
|
746
900
|
: {}),
|
|
@@ -771,6 +925,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
771
925
|
);
|
|
772
926
|
const listed =
|
|
773
927
|
needle === "" ? drawable : drawable.filter(matches);
|
|
928
|
+
// The panel search narrows the from/to rows too: a person who
|
|
929
|
+
// typed "price" is looking for the price picker as much as for
|
|
930
|
+
// a checkbox group, and leaving the rows in would answer the
|
|
931
|
+
// query with everything it did not match.
|
|
932
|
+
const listedRanges =
|
|
933
|
+
needle === ""
|
|
934
|
+
? ranges
|
|
935
|
+
: ranges.filter((group) =>
|
|
936
|
+
group.label.toLowerCase().includes(needle)
|
|
937
|
+
);
|
|
774
938
|
// The tail. Only while nothing is typed: a query has already
|
|
775
939
|
// narrowed the list, and folding its answer would hide the
|
|
776
940
|
// thing that was looked for. One group over the limit is not
|
|
@@ -828,29 +992,52 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
828
992
|
not decide — `facetGroupShape` reads the same config keys
|
|
829
993
|
the attributes editor reads, so a facet cannot look one
|
|
830
994
|
way here and another way in the composer. */}
|
|
995
|
+
{/* ONE PANEL, ONE ORDER. The bucket lists and the from/to
|
|
996
|
+
pickers are numbered on the same scale by the plan
|
|
997
|
+
(stapel-search 0.16.0), so they are drawn as one
|
|
998
|
+
sequence: "Price" and "Year" land where the category
|
|
999
|
+
authored them instead of above and below every choice.
|
|
1000
|
+
A host's `pinnedFacets` reaches both halves — see
|
|
1001
|
+
`orderPanelItems`, which also holds the band order used
|
|
1002
|
+
when nobody numbered anything. */}
|
|
831
1003
|
{/* The key changes with the query's presence ON PURPOSE:
|
|
832
1004
|
`defaultOpen` is an initial value, and a group the
|
|
833
1005
|
panel-search matched has to render OPEN — a hit behind
|
|
834
1006
|
a closed header is not an answer. Remounting is the
|
|
835
1007
|
honest way to re-ask the question; the person's own
|
|
836
1008
|
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
|
-
|
|
1009
|
+
{orderPanelItems({
|
|
1010
|
+
groups: shownGroups,
|
|
1011
|
+
ranges: listedRanges,
|
|
1012
|
+
...(props.pinnedFacets !== undefined
|
|
1013
|
+
? { pinned: props.pinnedFacets }
|
|
1014
|
+
: {}),
|
|
1015
|
+
}).map((item: PanelItem) =>
|
|
1016
|
+
item.kind === "range" ? (
|
|
1017
|
+
rangeRow(item.range)
|
|
1018
|
+
) : (
|
|
1019
|
+
<FacetGroupControl
|
|
1020
|
+
key={
|
|
1021
|
+
needle === ""
|
|
1022
|
+
? item.group.slug
|
|
1023
|
+
: `${item.group.slug}:match`
|
|
1024
|
+
}
|
|
1025
|
+
group={item.group}
|
|
1026
|
+
onToggle={bag.toggle}
|
|
1027
|
+
onSetValues={bag.setValues}
|
|
1028
|
+
collapsible
|
|
1029
|
+
defaultOpen={
|
|
1030
|
+
needle !== "" ||
|
|
1031
|
+
item.group.selected.length > 0 ||
|
|
1032
|
+
openByOrder.has(item.group.slug)
|
|
1033
|
+
}
|
|
1034
|
+
{...(props.dictionaryMode !== undefined
|
|
1035
|
+
? { dictionaryMode: props.dictionaryMode }
|
|
1036
|
+
: {})}
|
|
1037
|
+
/>
|
|
1038
|
+
)
|
|
1039
|
+
)}
|
|
1040
|
+
{rangesApplyButton(listedRanges)}
|
|
854
1041
|
{tailFolded && (
|
|
855
1042
|
<Button
|
|
856
1043
|
style={{ alignSelf: "flex-start" }}
|
|
@@ -876,77 +1063,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
876
1063
|
}}
|
|
877
1064
|
</LoadList>
|
|
878
1065
|
|
|
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
1066
|
{footerBar !== "none" && (
|
|
951
1067
|
<RailFooterBar
|
|
952
1068
|
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
|
|
@@ -559,7 +582,22 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
|
|
|
559
582
|
* keeps its own door.
|
|
560
583
|
*/
|
|
561
584
|
const hasChips = showCategoryChip || ordered.length > 0;
|
|
562
|
-
if (!hasChips)
|
|
585
|
+
if (!hasChips) {
|
|
586
|
+
// THE BOX THE ROW WILL ARRIVE INTO — see `CHIP_ROW_MIN_HEIGHT`. Only
|
|
587
|
+
// while the answer is still in flight and this search is one a row is
|
|
588
|
+
// predictable for; a settled answer with no chips renders nothing, which
|
|
589
|
+
// is what it has always done and what it should do.
|
|
590
|
+
const expected =
|
|
591
|
+
bag.state.status === "loading" &&
|
|
592
|
+
((props.categoryFeatures?.length ?? 0) > 0 || state.category !== undefined);
|
|
593
|
+
return expected ? (
|
|
594
|
+
<div
|
|
595
|
+
aria-hidden="true"
|
|
596
|
+
data-testid="search-filter-chips-reserve"
|
|
597
|
+
style={{ minBlockSize: CHIP_ROW_MIN_HEIGHT }}
|
|
598
|
+
/>
|
|
599
|
+
) : null;
|
|
600
|
+
}
|
|
563
601
|
|
|
564
602
|
return (
|
|
565
603
|
<>
|
|
@@ -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
|
>
|