@stapel/search-react 0.25.0 → 0.27.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 +50 -0
- package/MODULE.md +21 -9
- package/README.md +79 -18
- package/dist/api/generated/schema.d.ts +18 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +39 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +20 -13
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +29 -21
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +28 -11
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +53 -24
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +27 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +21 -8
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +12 -2
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +58 -6
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +0 -2
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -11
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +13 -14
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +0 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +9 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/facets.d.ts +62 -11
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +141 -29
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +77 -2
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +80 -6
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +18 -4
- 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 +18 -3
- package/src/api/types.ts +42 -4
- package/src/default/FacetGroupControl.tsx +27 -23
- package/src/default/FacetPanelPane.tsx +81 -33
- package/src/default/SearchPage.tsx +55 -6
- package/src/headless/SearchStateProvider.tsx +94 -4
- package/src/i18n/es.ts +0 -4
- package/src/i18n/generated/errors.es.gen.ts +1 -0
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/i18n/keys.ts +13 -16
- package/src/i18n/ru.ts +0 -8
- package/src/index.ts +13 -1
- package/src/model/queries.ts +11 -1
- package/src/state/facets.ts +173 -30
- package/src/state/urlState.ts +137 -5
|
@@ -181,15 +181,21 @@ export const FACET_OPEN_GROUPS = 5;
|
|
|
181
181
|
export const FACET_SEARCH_THRESHOLD = 6;
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* How many groups the rail draws before the rest go behind one control.
|
|
184
|
+
* How many groups the rail draws before the rest go behind one control. This
|
|
185
|
+
* is `<FacetPanelPane>`'s own default, for the desktop COLUMN — `<SearchPage>`
|
|
186
|
+
* passes 8 for the phone sheet instead (a surface reached through a modal
|
|
187
|
+
* already costs a tap; folding its tail behind a second one is not the same
|
|
188
|
+
* saving as folding a column that sits on screen the whole time).
|
|
185
189
|
*
|
|
186
|
-
* The reference classified
|
|
187
|
-
*
|
|
188
|
-
* a
|
|
189
|
-
*
|
|
190
|
-
*
|
|
190
|
+
* The reference classified inlines roughly two dozen groups in the desktop
|
|
191
|
+
* rail before anything folds — eight, this pair's number for three releases,
|
|
192
|
+
* turned a make, a price and a year into the whole visible rail with a
|
|
193
|
+
* "all filters" button under them. Sixteen is not a copy of the reference's
|
|
194
|
+
* count; it is the point past which the tail is genuinely a scan rather than
|
|
195
|
+
* a read, on the rail widths this pair is measured against. The tail is
|
|
196
|
+
* never hidden: `facetsAllFilters` names how many are in it.
|
|
191
197
|
*/
|
|
192
|
-
export const FACET_VISIBLE_GROUPS =
|
|
198
|
+
export const FACET_VISIBLE_GROUPS = 16;
|
|
193
199
|
|
|
194
200
|
/** What a host's category control is handed. */
|
|
195
201
|
export interface CategoryFilterSlotProps {
|
|
@@ -260,17 +266,28 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
260
266
|
*/
|
|
261
267
|
readonly skippedNotice?: boolean;
|
|
262
268
|
/**
|
|
263
|
-
* Draw the
|
|
264
|
-
*
|
|
265
|
-
* heading row — one control, not two) beside it. Default
|
|
269
|
+
* Draw the footer inside the panel: the live result count as the bar's
|
|
270
|
+
* strong text, and the clear-all control (which then moves out of the
|
|
271
|
+
* heading row — one control, not two) beside it. Default: no bar.
|
|
266
272
|
*
|
|
267
273
|
* `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
|
|
268
274
|
* apply instantly, so the bar is FEEDBACK plus the way out, not an apply
|
|
269
275
|
* button — which is exactly why the phone sheet must not get it: the sheet
|
|
270
276
|
* already closes through its own "Show N results" footer, and a second
|
|
271
277
|
* count-bearing bar above that one would be the same sentence twice.
|
|
278
|
+
*
|
|
279
|
+
* ── Where it sits, and why that is a choice ───────────────────────────────
|
|
280
|
+
*
|
|
281
|
+
* `"sticky"` (and `true`, which is what it has always meant) pins the bar to
|
|
282
|
+
* the bottom of the panel's own scroll port. That is right in a SHEET, whose
|
|
283
|
+
* port is the sheet and whose bar is the way out of it. In the desktop
|
|
284
|
+
* COLUMN the rail scrolls with the page, so an opaque bar pinned to the
|
|
285
|
+
* bottom of the viewport parks itself over the last two facet groups and
|
|
286
|
+
* they cannot be reached at all — a storefront was reaching for `!important`
|
|
287
|
+
* to lift it off. `"static"` puts the bar after the groups, where it stops
|
|
288
|
+
* covering anything, and the column layout of `<SearchPage>` passes it.
|
|
272
289
|
*/
|
|
273
|
-
readonly footerBar?: boolean;
|
|
290
|
+
readonly footerBar?: boolean | "sticky" | "static";
|
|
274
291
|
/**
|
|
275
292
|
* The partition control, drawn at the TOP of the panel — above the price,
|
|
276
293
|
* above every facet.
|
|
@@ -319,6 +336,9 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
319
336
|
function RailFooterBar(props: {
|
|
320
337
|
readonly activeFilters: number;
|
|
321
338
|
readonly clearAll: () => void;
|
|
339
|
+
/** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
|
|
340
|
+
* after the last group. See {@link FacetPanelPaneProps.footerBar}. */
|
|
341
|
+
readonly position: "sticky" | "static";
|
|
322
342
|
}): ReactElement | null {
|
|
323
343
|
const t = useT();
|
|
324
344
|
const tPlural = useTPlural();
|
|
@@ -340,9 +360,9 @@ function RailFooterBar(props: {
|
|
|
340
360
|
return (
|
|
341
361
|
<div
|
|
342
362
|
data-testid="facets-footer-bar"
|
|
363
|
+
data-position={props.position}
|
|
343
364
|
style={{
|
|
344
|
-
position: "sticky",
|
|
345
|
-
bottom: 0,
|
|
365
|
+
...(props.position === "sticky" ? { position: "sticky", bottom: 0 } : {}),
|
|
346
366
|
// Opaque, or the options scrolling under the bar read THROUGH it.
|
|
347
367
|
background: token.colorBgContainer,
|
|
348
368
|
borderBlockStart: `1px solid ${token.colorSplit}`,
|
|
@@ -470,33 +490,43 @@ function skippedNames(
|
|
|
470
490
|
|
|
471
491
|
/**
|
|
472
492
|
* The empty arm of the panel — the ONE place "this search offers no filters"
|
|
473
|
-
* may be said, and the
|
|
493
|
+
* may be said, and the three things that forbid it (D175, amended).
|
|
474
494
|
*
|
|
475
|
-
* A group list of zero is not the
|
|
476
|
-
* counter counted and then held back for describing too little of the
|
|
477
|
-
* set: they exist, so the
|
|
478
|
-
* the server could not work a plan out at all — the
|
|
479
|
-
* `<DegradationNotice>`, and this arm's only job is to
|
|
495
|
+
* A group list of zero is not "nothing on the rail". `withheld` names groups
|
|
496
|
+
* the counter counted and then held back for describing too little of the
|
|
497
|
+
* result set: they exist, so this is not the search's own claim to make.
|
|
498
|
+
* `planUnavailable` means the server could not work a plan out at all — the
|
|
499
|
+
* reader hears that from `<DegradationNotice>`, and this arm's only job is to
|
|
500
|
+
* not contradict it. `hasOtherDrawable` means the rail is already drawing
|
|
501
|
+
* something beside the facet groups — a price row, a location constraint, a
|
|
502
|
+
* partition — and "no filters" would be false the moment any of those is on
|
|
503
|
+
* screen.
|
|
504
|
+
*
|
|
505
|
+
* This USED to be two sentences: `facetsEmpty` for the last case and
|
|
506
|
+
* `facetsWithheld` — "N filters apply to too few of these results" — for the
|
|
507
|
+
* first. A reference catalogue checked against the same case says NEITHER:
|
|
508
|
+
* it leaves the filters visible with low counts and explains nothing. So
|
|
509
|
+
* `withheld` (and, the same way, `skipped`) now suppress this arm instead of
|
|
510
|
+
* replacing its text — the arm says nothing, and `data-withheld` is a test
|
|
511
|
+
* hook a shopper never reads.
|
|
480
512
|
*
|
|
481
513
|
* A COMPONENT rather than a ternary inline in `empty=`, because `LoadList`
|
|
482
514
|
* reads a nullish `empty` as "no arm given" and draws its own default, which
|
|
483
515
|
* is the sentence again. An element that renders `null` says nothing; a
|
|
484
516
|
* `null` prop says it louder.
|
|
485
517
|
*/
|
|
486
|
-
function FacetsEmptyArm(props: {
|
|
518
|
+
function FacetsEmptyArm(props: {
|
|
519
|
+
readonly bag: FacetPanelBag;
|
|
520
|
+
/** Something else on the rail already makes "no filters" false. */
|
|
521
|
+
readonly hasOtherDrawable: boolean;
|
|
522
|
+
}): ReactElement | null {
|
|
487
523
|
const t = useT();
|
|
488
|
-
const tPlural = useTPlural();
|
|
489
524
|
const withheld = props.bag.withheld.length;
|
|
490
|
-
if (withheld > 0) {
|
|
491
|
-
return (
|
|
492
|
-
<
|
|
493
|
-
|
|
494
|
-
title={tPlural(SEARCH_I18N_KEYS.facetsWithheld, { count: withheld })}
|
|
495
|
-
testId="facets-withheld"
|
|
496
|
-
/>
|
|
497
|
-
);
|
|
525
|
+
if (withheld > 0 || props.bag.planUnavailable || props.hasOtherDrawable) {
|
|
526
|
+
return withheld > 0 ? (
|
|
527
|
+
<span hidden data-testid="facets-withheld" data-withheld={withheld} />
|
|
528
|
+
) : null;
|
|
498
529
|
}
|
|
499
|
-
if (props.bag.planUnavailable) return null;
|
|
500
530
|
return (
|
|
501
531
|
<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />
|
|
502
532
|
);
|
|
@@ -513,6 +543,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
513
543
|
// panel's own search box: the URL is the search, and how much of the rail a
|
|
514
544
|
// person has unfolded is not part of it.
|
|
515
545
|
const [tailOpen, setTailOpen] = useState(false);
|
|
546
|
+
// `true` is the shape the prop shipped with and keeps meaning: pinned.
|
|
547
|
+
const footerBar: "sticky" | "static" | "none" =
|
|
548
|
+
props.footerBar === true
|
|
549
|
+
? "sticky"
|
|
550
|
+
: props.footerBar === false || props.footerBar === undefined
|
|
551
|
+
? "none"
|
|
552
|
+
: props.footerBar;
|
|
516
553
|
|
|
517
554
|
return (
|
|
518
555
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -550,6 +587,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
550
587
|
// classified catalogue means parcel weight and wholesale packing.
|
|
551
588
|
const coreRanges = ranges.filter((group) => group.core);
|
|
552
589
|
const attributeRanges = ranges.filter((group) => !group.core);
|
|
590
|
+
// Is there anything on this rail besides the facet groups? A price
|
|
591
|
+
// row, an applied location, or the partition slot all make "this
|
|
592
|
+
// search offers no filters" false even when the group list itself
|
|
593
|
+
// is empty. `state.geo` rather than a rendered control: the place
|
|
594
|
+
// and its radius are drawn by `<LocationSummaryLine>` beside this
|
|
595
|
+
// panel, not inside it, but the constraint is still on the rail.
|
|
596
|
+
const hasOtherDrawable =
|
|
597
|
+
props.partition !== undefined ||
|
|
598
|
+
coreRanges.length > 0 ||
|
|
599
|
+
state.geo !== undefined;
|
|
553
600
|
return (
|
|
554
601
|
<Flex vertical gap={spacing[3]} data-testid="search-facets">
|
|
555
602
|
{/* In a 280px rail this row laid the word "Filters" out in a
|
|
@@ -577,7 +624,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
577
624
|
{/* With the footer bar on, clear-all lives THERE — beside the
|
|
578
625
|
count it acts on — and drawing it here too would be two
|
|
579
626
|
identical exits one panel apart. */}
|
|
580
|
-
{bag.activeFilters > 0 &&
|
|
627
|
+
{bag.activeFilters > 0 && footerBar === "none" && (
|
|
581
628
|
<Button
|
|
582
629
|
style={FACET_CLEAR}
|
|
583
630
|
onClick={bag.clearAll}
|
|
@@ -657,7 +704,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
657
704
|
state={bag.state}
|
|
658
705
|
testId="facets"
|
|
659
706
|
skeletonRows={4}
|
|
660
|
-
empty={<FacetsEmptyArm bag={bag} />}
|
|
707
|
+
empty={<FacetsEmptyArm bag={bag} hasOtherDrawable={hasOtherDrawable} />}
|
|
661
708
|
failed={(error) => (
|
|
662
709
|
<ErrorAlert
|
|
663
710
|
testId="facets-failed"
|
|
@@ -845,10 +892,11 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
845
892
|
</>
|
|
846
893
|
)}
|
|
847
894
|
|
|
848
|
-
{
|
|
895
|
+
{footerBar !== "none" && (
|
|
849
896
|
<RailFooterBar
|
|
850
897
|
activeFilters={bag.activeFilters}
|
|
851
898
|
clearAll={bag.clearAll}
|
|
899
|
+
position={footerBar}
|
|
852
900
|
/>
|
|
853
901
|
)}
|
|
854
902
|
</Flex>
|
|
@@ -261,6 +261,33 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
261
261
|
/** Facet slugs pinned above every other group — see
|
|
262
262
|
* {@link FacetPanelPaneProps.pinnedFacets}. */
|
|
263
263
|
readonly pinnedFacets?: readonly string[];
|
|
264
|
+
/**
|
|
265
|
+
* How a DICTIONARY group is drawn — see
|
|
266
|
+
* {@link FacetPanelPaneProps.dictionaryMode}.
|
|
267
|
+
*
|
|
268
|
+
* Defaulted PER LAYOUT rather than left to the panel's own default, because
|
|
269
|
+
* the two frames want opposite shapes and only this component knows which
|
|
270
|
+
* one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
|
|
271
|
+
* that opens the searchable list — a 418-value vocabulary held open in a
|
|
272
|
+
* 280px column is the whole column), the phone sheet gets `"inline"`
|
|
273
|
+
* (the sheet is already the disclosure). Set it to override both.
|
|
274
|
+
*
|
|
275
|
+
* It was unreachable through this component until now: the panel had the
|
|
276
|
+
* prop, `<SearchPage>` forwarded nothing, and a storefront that mounts the
|
|
277
|
+
* page rather than the pane could not get the field at all.
|
|
278
|
+
*/
|
|
279
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
280
|
+
/**
|
|
281
|
+
* How many groups the rail draws before the rest fold behind "All filters"
|
|
282
|
+
* — see {@link FacetPanelPaneProps.visibleGroups}.
|
|
283
|
+
*
|
|
284
|
+
* Defaulted PER LAYOUT, the same reasoning as `dictionaryMode`: 16 in the
|
|
285
|
+
* desktop COLUMN, where the rail sits on screen the whole time and the tail
|
|
286
|
+
* is a genuine scroll away; 8 in the phone SHEET, which is already a modal
|
|
287
|
+
* a person paid a tap to open — folding its tail behind a second control
|
|
288
|
+
* saves less than folding a column's does. Set it to override either.
|
|
289
|
+
*/
|
|
290
|
+
readonly visibleGroups?: number | null;
|
|
264
291
|
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
265
292
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
266
293
|
readonly skippedNotice?: boolean;
|
|
@@ -422,6 +449,8 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
422
449
|
|
|
423
450
|
interface SearchPageBodyProps {
|
|
424
451
|
readonly renderCard?: SearchCardRenderer;
|
|
452
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
453
|
+
readonly visibleGroups?: number | null;
|
|
425
454
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
426
455
|
readonly renderEmptyExits?: () => ReactNode;
|
|
427
456
|
readonly locale?: string;
|
|
@@ -516,11 +545,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
516
545
|
const filtersEmpty =
|
|
517
546
|
facets.state.status === "ready" &&
|
|
518
547
|
facets.state.data.length === 0 &&
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
548
|
+
// `withheld` (groups the server counted and held back for covering too
|
|
549
|
+
// little of the result set) used to keep the column open so the panel
|
|
550
|
+
// could name how many. It no longer prints that sentence at all (D175,
|
|
551
|
+
// amended) — a reference catalogue says nothing in this case either — so
|
|
552
|
+
// zero groups plus zero of everything else below really is nothing to
|
|
553
|
+
// show.
|
|
524
554
|
facets.activeFilters === 0 &&
|
|
525
555
|
ranges.length === 0 &&
|
|
526
556
|
state.category === undefined &&
|
|
@@ -545,7 +575,22 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
545
575
|
result count scrolled out of sight above the fold. */}
|
|
546
576
|
{filtersEmpty ? null : (
|
|
547
577
|
<FacetPanelPane
|
|
548
|
-
{...(layout === "sheet"
|
|
578
|
+
{...(layout === "sheet"
|
|
579
|
+
? { heading: null }
|
|
580
|
+
: // STATIC, not sticky: the rail scrolls with the page, and a bar
|
|
581
|
+
// pinned to the port's floor sat on top of the last groups.
|
|
582
|
+
{ footerBar: "static" as const })}
|
|
583
|
+
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "inline" : "field")}
|
|
584
|
+
// `??` would treat an explicit `null` ("never fold") the same as
|
|
585
|
+
// "not set": `visibleGroups` uses `null` as a real value, unlike
|
|
586
|
+
// `dictionaryMode` above, so only `undefined` falls through.
|
|
587
|
+
visibleGroups={
|
|
588
|
+
props.visibleGroups !== undefined
|
|
589
|
+
? props.visibleGroups
|
|
590
|
+
: layout === "sheet"
|
|
591
|
+
? 8
|
|
592
|
+
: 16
|
|
593
|
+
}
|
|
549
594
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
550
595
|
{...(props.renderEmptyExits !== undefined
|
|
551
596
|
? { renderEmptyExits: props.renderEmptyExits }
|
|
@@ -805,6 +850,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
805
850
|
onViewChange,
|
|
806
851
|
resultsAction,
|
|
807
852
|
resultsHeadingLevel,
|
|
853
|
+
dictionaryMode,
|
|
854
|
+
visibleGroups,
|
|
808
855
|
mode,
|
|
809
856
|
...parseOptions
|
|
810
857
|
} = props;
|
|
@@ -814,6 +861,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
814
861
|
<SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
|
|
815
862
|
<SearchPageBody
|
|
816
863
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
864
|
+
{...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
|
|
865
|
+
{...(visibleGroups !== undefined ? { visibleGroups } : {})}
|
|
817
866
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
818
867
|
{...(locale !== undefined ? { locale } : {})}
|
|
819
868
|
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
@@ -2,17 +2,22 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
|
+
useEffect,
|
|
5
6
|
useMemo,
|
|
7
|
+
useState,
|
|
6
8
|
} from "react";
|
|
7
9
|
import type { ReactElement, ReactNode } from "react";
|
|
8
10
|
import type {
|
|
11
|
+
FacetLabelsMap,
|
|
9
12
|
SearchGeo,
|
|
10
13
|
SearchQueryState,
|
|
11
14
|
SearchRange,
|
|
12
15
|
} from "../api/types.js";
|
|
13
16
|
import {
|
|
17
|
+
EMPTY_FACET_KEYS,
|
|
14
18
|
activeFilterCount,
|
|
15
19
|
clearFilters,
|
|
20
|
+
facetKeyMapFromLabels,
|
|
16
21
|
parseSearchState,
|
|
17
22
|
patchSearchState,
|
|
18
23
|
setFilterValues,
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
writeSearchState,
|
|
22
27
|
} from "../state/urlState.js";
|
|
23
28
|
import type {
|
|
29
|
+
FacetKeyMap,
|
|
24
30
|
ParseSearchStateOptions,
|
|
25
31
|
SearchStateIssue,
|
|
26
32
|
SearchStatePatch,
|
|
@@ -132,6 +138,56 @@ function sameCenter(
|
|
|
132
138
|
|
|
133
139
|
const StateContext = createContext<SearchStateBag | null>(null);
|
|
134
140
|
|
|
141
|
+
/**
|
|
142
|
+
* WHERE THE SHORT KEYS COME FROM.
|
|
143
|
+
*
|
|
144
|
+
* `f.make` is a fact of the ANSWER (`facet_labels[slug].url_key`, resolved by
|
|
145
|
+
* the server inside the queried category's scope), and the codec that writes
|
|
146
|
+
* the address runs above the query that produces it. So the map is published
|
|
147
|
+
* upwards: whoever holds an answer hands it to this provider, which re-parses
|
|
148
|
+
* the URL with it and writes every subsequent address through it.
|
|
149
|
+
*
|
|
150
|
+
* Late by construction and correct at every moment in between: before the
|
|
151
|
+
* first answer the state holds whatever key the link carried, the request
|
|
152
|
+
* carries the same key, and the server resolves both forms. Nothing waits and
|
|
153
|
+
* nothing is rewritten behind the reader.
|
|
154
|
+
*/
|
|
155
|
+
interface FacetKeyRegistry {
|
|
156
|
+
readonly keys: FacetKeyMap;
|
|
157
|
+
publish(next: FacetKeyMap): void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const FacetKeysContext = createContext<FacetKeyRegistry | null>(null);
|
|
161
|
+
|
|
162
|
+
/** Two maps are the same map when they write the same keys — the read side is
|
|
163
|
+
* derived from the write side, so comparing one compares both. */
|
|
164
|
+
function sameKeys(a: FacetKeyMap, b: FacetKeyMap): boolean {
|
|
165
|
+
const left = Object.keys(a.write);
|
|
166
|
+
const right = Object.keys(b.write);
|
|
167
|
+
if (left.length !== right.length) return false;
|
|
168
|
+
return left.every((slug) => a.write[slug] === b.write[slug]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Publish an answer's short keys to the state provider above.
|
|
173
|
+
*
|
|
174
|
+
* A no-op outside `<SearchStateProvider>` and a no-op on a server that sends
|
|
175
|
+
* no `url_key`: both leave the address spelled in slugs, which is what it was
|
|
176
|
+
* spelled in before this existed.
|
|
177
|
+
*/
|
|
178
|
+
export function usePublishFacetKeys(labels: FacetLabelsMap | undefined): void {
|
|
179
|
+
const registry = useContext(FacetKeysContext);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (registry === null || labels === undefined) return;
|
|
182
|
+
registry.publish(facetKeyMapFromLabels(labels));
|
|
183
|
+
}, [registry, labels]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The short-key map this search is currently writing its address with. */
|
|
187
|
+
export function useFacetKeys(): FacetKeyMap {
|
|
188
|
+
return useContext(FacetKeysContext)?.keys ?? EMPTY_FACET_KEYS;
|
|
189
|
+
}
|
|
190
|
+
|
|
135
191
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
136
192
|
readonly adapter: SearchParamsAdapter;
|
|
137
193
|
/**
|
|
@@ -193,9 +249,27 @@ export function SearchStateProvider(
|
|
|
193
249
|
|
|
194
250
|
const search = params.toString();
|
|
195
251
|
|
|
252
|
+
// The answer's short keys (`f.make`), published from below — see
|
|
253
|
+
// `FacetKeysContext`. Held here because this is where both directions of
|
|
254
|
+
// the codec run.
|
|
255
|
+
const [facetKeys, setFacetKeys] = useState<FacetKeyMap>(EMPTY_FACET_KEYS);
|
|
256
|
+
const registry = useMemo<FacetKeyRegistry>(
|
|
257
|
+
() => ({
|
|
258
|
+
keys: facetKeys,
|
|
259
|
+
publish: (next) => {
|
|
260
|
+
// Idempotent on purpose: this is called from an effect under a query
|
|
261
|
+
// whose data identity is stable, and a setState that always produced
|
|
262
|
+
// a new object would re-render the whole page per answer.
|
|
263
|
+
setFacetKeys((was) => (sameKeys(was, next) ? was : next));
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
[facetKeys]
|
|
267
|
+
);
|
|
268
|
+
|
|
196
269
|
const parsed = useMemo(
|
|
197
270
|
() =>
|
|
198
271
|
parseSearchState(new URLSearchParams(search), {
|
|
272
|
+
facetKeys,
|
|
199
273
|
defaultType,
|
|
200
274
|
...(defaultQ !== undefined ? { defaultQ } : {}),
|
|
201
275
|
...(defaultSort !== undefined ? { defaultSort } : {}),
|
|
@@ -203,14 +277,26 @@ export function SearchStateProvider(
|
|
|
203
277
|
...(defaultCategory !== undefined ? { defaultCategory } : {}),
|
|
204
278
|
...(defaultLang !== undefined ? { defaultLang } : {}),
|
|
205
279
|
}),
|
|
206
|
-
[
|
|
280
|
+
[
|
|
281
|
+
search,
|
|
282
|
+
facetKeys,
|
|
283
|
+
defaultType,
|
|
284
|
+
defaultQ,
|
|
285
|
+
defaultSort,
|
|
286
|
+
defaultLimit,
|
|
287
|
+
defaultCategory,
|
|
288
|
+
defaultLang,
|
|
289
|
+
]
|
|
207
290
|
);
|
|
208
291
|
|
|
209
292
|
const commit = useCallback(
|
|
210
293
|
(next: SearchQueryState, options?: { readonly replace?: boolean }): void => {
|
|
211
|
-
setParams(
|
|
294
|
+
setParams(
|
|
295
|
+
writeSearchState(next, new URLSearchParams(search), facetKeys),
|
|
296
|
+
options
|
|
297
|
+
);
|
|
212
298
|
},
|
|
213
|
-
[setParams, search]
|
|
299
|
+
[setParams, search, facetKeys]
|
|
214
300
|
);
|
|
215
301
|
|
|
216
302
|
|
|
@@ -280,7 +366,11 @@ export function SearchStateProvider(
|
|
|
280
366
|
}, [parsed, commit, geoOffer]);
|
|
281
367
|
|
|
282
368
|
|
|
283
|
-
return
|
|
369
|
+
return (
|
|
370
|
+
<FacetKeysContext.Provider value={registry}>
|
|
371
|
+
<StateContext.Provider value={bag}>{children}</StateContext.Provider>
|
|
372
|
+
</FacetKeysContext.Provider>
|
|
373
|
+
);
|
|
284
374
|
}
|
|
285
375
|
|
|
286
376
|
/**
|
package/src/i18n/es.ts
CHANGED
|
@@ -75,10 +75,6 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
75
75
|
"Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
|
|
76
76
|
"search.facets.skipped":
|
|
77
77
|
"Estos filtros no se contaron para esta búsqueda: {slugs}",
|
|
78
|
-
"search.facets.withheld.one":
|
|
79
|
-
"{count} filtro se aplica a muy pocos de estos resultados",
|
|
80
|
-
"search.facets.withheld.other":
|
|
81
|
-
"{count} filtros se aplican a muy pocos de estos resultados",
|
|
82
78
|
"search.facets.not_counted": "sin contar",
|
|
83
79
|
"search.facets.drill_down_hint":
|
|
84
80
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
@@ -35,6 +35,7 @@ export const searchErrorBundleEs: Record<SearchErrorCode, string> = {
|
|
|
35
35
|
"error.400.search_sort_needs_center": "Ordenar por distancia requiere lat y lon",
|
|
36
36
|
"error.400.search_too_many_facets": "Demasiados filtros de faceta (límite {limit})",
|
|
37
37
|
"error.400.search_too_many_ranges": "Demasiados filtros de rango (límite {limit})",
|
|
38
|
+
"error.400.search_unknown_category": "No existe la categoría «{category}». `category` admite el id del nodo o su ruta raíz/hoja.",
|
|
38
39
|
"error.400.search_unknown_doc_type": "Tipo de búsqueda desconocido «{doc_type}»",
|
|
39
40
|
"error.400.search_unknown_sort": "Orden desconocido «{sort}»",
|
|
40
41
|
"error.400.search_window_exceeded": "Esta página de resultados supera el límite de {window}. Acote la búsqueda en lugar de avanzar más páginas.",
|
|
@@ -58,6 +58,7 @@ export const SEARCH_ERRORS = {
|
|
|
58
58
|
"error.400.search_sort_needs_center": { status: 400, params: [], remediation: "fix_input", en: "Sorting by distance needs lat and lon" },
|
|
59
59
|
"error.400.search_too_many_facets": { status: 400, params: ["limit"], remediation: "fix_input", en: "Too many facet filters (limit {limit})" },
|
|
60
60
|
"error.400.search_too_many_ranges": { status: 400, params: ["limit"], remediation: "fix_input", en: "Too many range filters (limit {limit})" },
|
|
61
|
+
"error.400.search_unknown_category": { status: 400, params: ["category"], remediation: "fix_input", en: "No category '{category}' exists. `category` takes the node id or its root/leaf path." },
|
|
61
62
|
"error.400.search_unknown_doc_type": { status: 400, params: ["doc_type"], remediation: "fix_input", en: "Unknown search type '{doc_type}'" },
|
|
62
63
|
"error.400.search_unknown_sort": { status: 400, params: ["sort"], remediation: "fix_input", en: "Unknown sort '{sort}'" },
|
|
63
64
|
"error.400.search_window_exceeded": { status: 400, params: ["window"], remediation: "fix_input", en: "This result page is beyond the maximum window of {window}. Narrow the search instead of paging deeper." },
|
|
@@ -118,6 +119,7 @@ export const SEARCH_ERROR_CODES: readonly SearchErrorCode[] = [
|
|
|
118
119
|
"error.400.search_sort_needs_center",
|
|
119
120
|
"error.400.search_too_many_facets",
|
|
120
121
|
"error.400.search_too_many_ranges",
|
|
122
|
+
"error.400.search_unknown_category",
|
|
121
123
|
"error.400.search_unknown_doc_type",
|
|
122
124
|
"error.400.search_unknown_sort",
|
|
123
125
|
"error.400.search_window_exceeded",
|
|
@@ -180,6 +182,7 @@ export const searchErrorBundleEn: Record<SearchErrorCode, string> = {
|
|
|
180
182
|
"error.400.search_sort_needs_center": "Sorting by distance needs lat and lon",
|
|
181
183
|
"error.400.search_too_many_facets": "Too many facet filters (limit {limit})",
|
|
182
184
|
"error.400.search_too_many_ranges": "Too many range filters (limit {limit})",
|
|
185
|
+
"error.400.search_unknown_category": "No category '{category}' exists. `category` takes the node id or its root/leaf path.",
|
|
183
186
|
"error.400.search_unknown_doc_type": "Unknown search type '{doc_type}'",
|
|
184
187
|
"error.400.search_unknown_sort": "Unknown sort '{sort}'",
|
|
185
188
|
"error.400.search_window_exceeded": "This result page is beyond the maximum window of {window}. Narrow the search instead of paging deeper.",
|
|
@@ -195,6 +195,15 @@
|
|
|
195
195
|
"remediation": "fix_input",
|
|
196
196
|
"en": "Too many range filters (limit {limit})"
|
|
197
197
|
},
|
|
198
|
+
{
|
|
199
|
+
"code": "error.400.search_unknown_category",
|
|
200
|
+
"status": 400,
|
|
201
|
+
"params": [
|
|
202
|
+
"category"
|
|
203
|
+
],
|
|
204
|
+
"remediation": "fix_input",
|
|
205
|
+
"en": "No category '{category}' exists. `category` takes the node id or its root/leaf path."
|
|
206
|
+
},
|
|
198
207
|
{
|
|
199
208
|
"code": "error.400.search_unknown_doc_type",
|
|
200
209
|
"status": 400,
|
|
@@ -35,6 +35,7 @@ export const searchErrorBundleRu: Record<SearchErrorCode, string> = {
|
|
|
35
35
|
"error.400.search_sort_needs_center": "Сортировка по расстоянию требует координат (lat и lon)",
|
|
36
36
|
"error.400.search_too_many_facets": "Слишком много фильтров-фасетов (предел {limit})",
|
|
37
37
|
"error.400.search_too_many_ranges": "Слишком много диапазонных фильтров (предел {limit})",
|
|
38
|
+
"error.400.search_unknown_category": "Категории «{category}» не существует. В `category` передаётся id узла или путь корень/лист.",
|
|
38
39
|
"error.400.search_unknown_doc_type": "Неизвестный тип поиска «{doc_type}»",
|
|
39
40
|
"error.400.search_unknown_sort": "Неизвестная сортировка «{sort}»",
|
|
40
41
|
"error.400.search_window_exceeded": "Эта страница выдачи выходит за предел в {window} результатов. Уточните запрос вместо перелистывания вглубь.",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -111,22 +111,24 @@ export const SEARCH_I18N_KEYS = {
|
|
|
111
111
|
facetsTitle: "search.facets.title",
|
|
112
112
|
facetsLoading: "search.facets.loading",
|
|
113
113
|
facetsLoadFailed: "search.facets.load_failed",
|
|
114
|
+
/**
|
|
115
|
+
* "This search offers no filters" — the ONE sentence the empty arm may
|
|
116
|
+
* print, and only when the rail draws nothing else either (no price, no
|
|
117
|
+
* location, no partition — see `FacetsEmptyArm`'s `hasOtherDrawable`).
|
|
118
|
+
*
|
|
119
|
+
* D175, amended: this used to be one of TWO sentences, the other keyed on
|
|
120
|
+
* `facet_meta.withheld` ("N filters apply to too few of these results").
|
|
121
|
+
* A reference catalogue was checked for the same case and says neither —
|
|
122
|
+
* it leaves the filters visible with low counts and no explanation of why
|
|
123
|
+
* some are thin. `withheld` (and `skipped`) now suppress this sentence
|
|
124
|
+
* instead of replacing it; a test that needs the count reads
|
|
125
|
+
* `data-withheld` on the empty arm, which carries no text.
|
|
126
|
+
*/
|
|
114
127
|
facetsEmpty: "search.facets.empty",
|
|
115
128
|
facetsClear: "search.facets.clear",
|
|
116
129
|
facetsClearAll: "search.facets.clear_all",
|
|
117
130
|
facetsApproximate: "search.facets.approximate",
|
|
118
131
|
facetsSkipped: "search.facets.skipped",
|
|
119
|
-
/**
|
|
120
|
-
* "N filters apply to too few of these results" — the sentence that
|
|
121
|
-
* REPLACES {@link facetsEmpty} whenever `facet_meta.withheld` is not
|
|
122
|
-
* empty. A PLURAL FAMILY: it counts a noun in words.
|
|
123
|
-
*
|
|
124
|
-
* D175: a branch category holding 46 phones that all carry a manufacturer
|
|
125
|
-
* printed "this search offers no filters". The groups were counted and
|
|
126
|
-
* then withheld for covering too little of the set — so "there are none"
|
|
127
|
-
* was false, and the honest sentence is this one, with the number in it.
|
|
128
|
-
*/
|
|
129
|
-
facetsWithheld: "search.facets.withheld",
|
|
130
132
|
/** Shown INSTEAD of a count for a slug the server did not count. */
|
|
131
133
|
facetsNotCounted: "search.facets.not_counted",
|
|
132
134
|
facetsDrillDownHint: "search.facets.drill_down_hint",
|
|
@@ -471,7 +473,6 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
471
473
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
472
474
|
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
473
475
|
SEARCH_I18N_KEYS.facetsMatchCount,
|
|
474
|
-
SEARCH_I18N_KEYS.facetsWithheld,
|
|
475
476
|
];
|
|
476
477
|
|
|
477
478
|
/**
|
|
@@ -544,10 +545,6 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
544
545
|
"Counts are approximate — there were too many candidates to count them all.",
|
|
545
546
|
"search.facets.skipped":
|
|
546
547
|
"These filters were not counted for this search: {slugs}",
|
|
547
|
-
"search.facets.withheld.one":
|
|
548
|
-
"{count} filter applies to too few of these results",
|
|
549
|
-
"search.facets.withheld.other":
|
|
550
|
-
"{count} filters apply to too few of these results",
|
|
551
548
|
"search.facets.not_counted": "not counted",
|
|
552
549
|
"search.facets.drill_down_hint":
|
|
553
550
|
"Each count is what you would get by choosing that value instead of the one you have.",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -100,14 +100,6 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
100
100
|
"Счётчики приблизительные — подходящих объявлений слишком много, чтобы пересчитать все.",
|
|
101
101
|
"search.facets.skipped":
|
|
102
102
|
"Эти фильтры для текущего поиска не посчитаны: {slugs}",
|
|
103
|
-
"search.facets.withheld.one":
|
|
104
|
-
"Ещё {count} фильтр подходит слишком малой части результатов",
|
|
105
|
-
"search.facets.withheld.few":
|
|
106
|
-
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
107
|
-
"search.facets.withheld.many":
|
|
108
|
-
"Ещё {count} фильтров подходят слишком малой части результатов",
|
|
109
|
-
"search.facets.withheld.other":
|
|
110
|
-
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
111
103
|
"search.facets.not_counted": "не посчитано",
|
|
112
104
|
"search.facets.drill_down_hint":
|
|
113
105
|
"Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
|
package/src/index.ts
CHANGED
|
@@ -77,11 +77,16 @@ export type {
|
|
|
77
77
|
|
|
78
78
|
// ── state (pure: no React, no router) ────────────────────────────────────────
|
|
79
79
|
export {
|
|
80
|
+
EMPTY_FACET_KEYS,
|
|
80
81
|
FILTER_PREFIX,
|
|
81
82
|
RANGE_PREFIX,
|
|
82
83
|
SEARCH_PARAM,
|
|
83
84
|
activeFilterCount,
|
|
85
|
+
buildFacetKeyMap,
|
|
84
86
|
clearFilters,
|
|
87
|
+
facetKeyForSlug,
|
|
88
|
+
facetKeyMapFromLabels,
|
|
89
|
+
facetSlugForKey,
|
|
85
90
|
ownsParam,
|
|
86
91
|
parseSearchState,
|
|
87
92
|
patchSearchState,
|
|
@@ -91,6 +96,7 @@ export {
|
|
|
91
96
|
writeSearchState,
|
|
92
97
|
} from "./state/urlState.js";
|
|
93
98
|
export type {
|
|
99
|
+
FacetKeyMap,
|
|
94
100
|
ParseSearchStateOptions,
|
|
95
101
|
ParsedSearchState,
|
|
96
102
|
SearchStateIssue,
|
|
@@ -118,6 +124,7 @@ export {
|
|
|
118
124
|
buildFacetGroups,
|
|
119
125
|
facetGroupHasEvidence,
|
|
120
126
|
facetGroupIsDrawable,
|
|
127
|
+
facetGroupIsVocabularyBacked,
|
|
121
128
|
facetOptionLabel,
|
|
122
129
|
isFacetableFeature,
|
|
123
130
|
orderFacetGroupsBySchema,
|
|
@@ -177,7 +184,12 @@ export { useRankingDisclosure, useSearchQuery, useSuggest } from "./model/querie
|
|
|
177
184
|
|
|
178
185
|
// ── headless (renderless components) ─────────────────────────────────────────
|
|
179
186
|
export { SearchProvider } from "./headless/SearchProvider.js";
|
|
180
|
-
export {
|
|
187
|
+
export {
|
|
188
|
+
SearchStateProvider,
|
|
189
|
+
useFacetKeys,
|
|
190
|
+
usePublishFacetKeys,
|
|
191
|
+
useSearchState,
|
|
192
|
+
} from "./headless/SearchStateProvider.js";
|
|
181
193
|
export type {
|
|
182
194
|
SearchParamsAdapter,
|
|
183
195
|
SearchStateBag,
|