@stapel/search-react 0.15.0 → 0.18.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 +132 -0
- package/dist/api/generated/schema.d.ts +20 -10
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +10 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +122 -27
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +49 -35
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +158 -7
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +25 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +39 -3
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts +3 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +15 -4
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +9 -1
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +20 -7
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +1 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +5 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +19 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +25 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +7 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/state/facets.d.ts +14 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +59 -10
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +8 -2
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +20 -10
- package/src/default/FacetGroupControl.tsx +217 -60
- package/src/default/FacetPanelPane.tsx +255 -27
- package/src/default/FilterChips.tsx +69 -2
- package/src/default/SearchPage.tsx +24 -1
- package/src/default/SearchResultsPane.tsx +20 -7
- package/src/default/index.ts +2 -0
- package/src/headless/FacetPanel.tsx +1 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +25 -0
- package/src/i18n/ru.ts +7 -0
- package/src/state/facets.ts +55 -8
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* genuinely are a sample above the backend's candidate cap.
|
|
18
18
|
* - `skipped` — the slugs the server did not count at all. Their options show
|
|
19
19
|
* "not counted", never `0`. A silent zero there is the same defect class as
|
|
20
|
-
* `data ?? []`: a number that looks like an answer and is not one.
|
|
20
|
+
* `data ?? []`: a number that looks like an answer and is not one. The
|
|
21
|
+
* facet itself is still DRAWN, from the category schema, because the server
|
|
22
|
+
* filters on a slug it never counted; the engine's own list of skipped
|
|
23
|
+
* slugs is a developer's note and lives behind `skippedNotice`.
|
|
21
24
|
*
|
|
22
25
|
* ── Two slots, and why they are slots ─────────────────────────────────────
|
|
23
26
|
*
|
|
@@ -32,17 +35,44 @@
|
|
|
32
35
|
* than a silent absence — and, in every build, any constraint the URL already
|
|
33
36
|
* carries gets a control that REMOVES it. A shared link that narrows to a
|
|
34
37
|
* category or a point must never leave a person with no way to widen it again.
|
|
38
|
+
*
|
|
39
|
+
* ── The panel opens what the answer argues for, and closes the rest ────────
|
|
40
|
+
*
|
|
41
|
+
* Measured on a live classified deployment's cars leaf at 1440×900: this
|
|
42
|
+
* panel, in a 280px rail, was 5717px tall — 40 groups, 118 checkboxes, 66
|
|
43
|
+
* fields, one flat column whose tail no scroll a person actually performs
|
|
44
|
+
* ever reaches. The phone sheet drew the same column six screens deep.
|
|
45
|
+
*
|
|
46
|
+
* So the groups are disclosures now, and WHICH open is decided here, from
|
|
47
|
+
* evidence the panel already holds: a group with any chosen value is always
|
|
48
|
+
* open (a constraint must keep its control in sight), and otherwise the top
|
|
49
|
+
* {@link FACET_OPEN_GROUPS} counted groups by {@link facetCoverage} — the sum
|
|
50
|
+
* of an axis's counts is the answer's own statement of how many documents
|
|
51
|
+
* carry it, the same reasoning the chip row sorts its band by. Everything
|
|
52
|
+
* else starts as a header, one click from whole. A group the server never
|
|
53
|
+
* counted sums to zero and therefore never opens uninvited, which is what
|
|
54
|
+
* removes the wall of "not counted" rows from the default view without
|
|
55
|
+
* deleting one of them.
|
|
56
|
+
*
|
|
57
|
+
* From {@link FACET_SEARCH_THRESHOLD} groups up the panel also takes a search
|
|
58
|
+
* of ITSELF — a person who cannot scan forty headers should not have to open
|
|
59
|
+
* them one by one to find the axle count. It narrows by group or option
|
|
60
|
+
* label, opens what it matches, and touches presentation only: the URL is
|
|
61
|
+
* state and a panel query is not.
|
|
35
62
|
*/
|
|
63
|
+
import { useState } from "react";
|
|
36
64
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
37
65
|
import {
|
|
38
66
|
Alert,
|
|
39
67
|
Button,
|
|
40
68
|
Divider,
|
|
41
69
|
Flex,
|
|
70
|
+
Input,
|
|
42
71
|
InputNumber,
|
|
43
72
|
Typography,
|
|
73
|
+
theme,
|
|
44
74
|
} from "antd";
|
|
45
|
-
import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
|
|
75
|
+
import { SlotPlaceholder, isDevBuild, useT, useTPlural } from "@stapel/core";
|
|
46
76
|
import {
|
|
47
77
|
EmptyState,
|
|
48
78
|
ErrorAlert,
|
|
@@ -56,6 +86,9 @@ import type { SearchGeo } from "../api/types.js";
|
|
|
56
86
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
57
87
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
58
88
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
89
|
+
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
90
|
+
import { facetCoverage } from "../state/facets.js";
|
|
91
|
+
import type { FacetGroup } from "../state/facets.js";
|
|
59
92
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
60
93
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
61
94
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -120,6 +153,21 @@ const FACET_CLEAR: CSSProperties = {
|
|
|
120
153
|
textAlign: "start",
|
|
121
154
|
};
|
|
122
155
|
|
|
156
|
+
/**
|
|
157
|
+
* How many counted groups open by default — beyond the ones with chosen
|
|
158
|
+
* values, which always do. Five open groups at the default type step fill
|
|
159
|
+
* roughly one 900px window of the rail: the panel's first screen is the five
|
|
160
|
+
* axes this corpus is most narrowed by, and everything after is a header.
|
|
161
|
+
*/
|
|
162
|
+
export const FACET_OPEN_GROUPS = 5;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* From how many groups the panel offers a search of itself. Under six the
|
|
166
|
+
* headers fit a glance and a box would be chrome over nothing; the measured
|
|
167
|
+
* leaf had forty.
|
|
168
|
+
*/
|
|
169
|
+
export const FACET_SEARCH_THRESHOLD = 6;
|
|
170
|
+
|
|
123
171
|
/** What a host's category control is handed. */
|
|
124
172
|
export interface CategoryFilterSlotProps {
|
|
125
173
|
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
@@ -183,6 +231,111 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
183
231
|
* "Clear all" control lives in it.
|
|
184
232
|
*/
|
|
185
233
|
readonly heading?: ReactNode;
|
|
234
|
+
/**
|
|
235
|
+
* Print the engine's own note about which slugs it did not COUNT
|
|
236
|
+
* (`facet_meta.skipped`). Default `false` — off, and off for a shopper.
|
|
237
|
+
*
|
|
238
|
+
* ── Why this is opt-in and was not ────────────────────────────────────────
|
|
239
|
+
*
|
|
240
|
+
* The sentence is true and it is not a shopper's sentence. On a live cars
|
|
241
|
+
* leaf it rendered as a yellow warning naming forty-two of the category's
|
|
242
|
+
* own fields — climate-control sub-options, a video-file URL, nine dealer
|
|
243
|
+
* promotions — stacked above the filters. The same class of engine plumbing
|
|
244
|
+
* as the synonym-expansion notice this pair removed earlier, and the owner
|
|
245
|
+
* read it exactly that way: as the page saying something was broken.
|
|
246
|
+
*
|
|
247
|
+
* It was also, until this release, a WARNING ABOUT A CONSEQUENCE THE PANEL
|
|
248
|
+
* IMPOSED: an uncounted facet drew no options, so the note named filters
|
|
249
|
+
* the person could not then use. `buildFacetGroups` now builds those
|
|
250
|
+
* options from the category schema, so the filters are there, counted or
|
|
251
|
+
* not, and the only thing missing is the number beside each option — which
|
|
252
|
+
* every option already says for itself.
|
|
253
|
+
*
|
|
254
|
+
* `true` puts it back, for a developer looking at a deployment's facet plan
|
|
255
|
+
* on a staging surface. Nothing else changes with it.
|
|
256
|
+
*/
|
|
257
|
+
readonly skippedNotice?: boolean;
|
|
258
|
+
/**
|
|
259
|
+
* Draw the sticky footer inside the panel: the live result count as the
|
|
260
|
+
* bar's strong text, and the clear-all control (which then moves out of the
|
|
261
|
+
* heading row — one control, not two) beside it. Default `false`.
|
|
262
|
+
*
|
|
263
|
+
* `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
|
|
264
|
+
* apply instantly, so the bar is FEEDBACK plus the way out, not an apply
|
|
265
|
+
* button — which is exactly why the phone sheet must not get it: the sheet
|
|
266
|
+
* already closes through its own "Show N results" footer, and a second
|
|
267
|
+
* count-bearing bar above that one would be the same sentence twice.
|
|
268
|
+
*/
|
|
269
|
+
readonly footerBar?: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The rail's sticky floor: what the filters DID (the live count), and the way
|
|
274
|
+
* out of them (clear all). It sticks to the bottom of the rail's own scroll,
|
|
275
|
+
* so however deep the panel goes the answer stays on screen.
|
|
276
|
+
*
|
|
277
|
+
* The count reuses `useAppliedCount` — the answer already in cache, never a
|
|
278
|
+
* second request — and its honesty rules: an exact count gets the counted
|
|
279
|
+
* noun ("N listings match"), a floor keeps the existing "N+" family, and an
|
|
280
|
+
* engine that cannot say gets NO number, because a fabricated one on the
|
|
281
|
+
* surface that reports what filtering did is worse than silence. With
|
|
282
|
+
* nothing to say and nothing to clear, no bar.
|
|
283
|
+
*/
|
|
284
|
+
function RailFooterBar(props: {
|
|
285
|
+
readonly activeFilters: number;
|
|
286
|
+
readonly clearAll: () => void;
|
|
287
|
+
}): ReactElement | null {
|
|
288
|
+
const t = useT();
|
|
289
|
+
const tPlural = useTPlural();
|
|
290
|
+
// The token bag of the nearest theme, so the bar's ground and hairline are
|
|
291
|
+
// the panel's own in both modes — a hard-coded white floor would glow in
|
|
292
|
+
// the dark theme.
|
|
293
|
+
const { token } = theme.useToken();
|
|
294
|
+
const applied = useAppliedCount();
|
|
295
|
+
const countText =
|
|
296
|
+
applied.count === null || applied.kind === "unknown"
|
|
297
|
+
? null
|
|
298
|
+
: tPlural(
|
|
299
|
+
applied.kind === "at_least"
|
|
300
|
+
? SEARCH_I18N_KEYS.resultsCountAtLeast
|
|
301
|
+
: SEARCH_I18N_KEYS.facetsMatchCount,
|
|
302
|
+
{ count: applied.count }
|
|
303
|
+
);
|
|
304
|
+
if (countText === null && props.activeFilters === 0) return null;
|
|
305
|
+
return (
|
|
306
|
+
<div
|
|
307
|
+
data-testid="facets-footer-bar"
|
|
308
|
+
style={{
|
|
309
|
+
position: "sticky",
|
|
310
|
+
bottom: 0,
|
|
311
|
+
// Opaque, or the options scrolling under the bar read THROUGH it.
|
|
312
|
+
background: token.colorBgContainer,
|
|
313
|
+
borderBlockStart: `1px solid ${token.colorSplit}`,
|
|
314
|
+
paddingBlockStart: spacing[2],
|
|
315
|
+
display: "flex",
|
|
316
|
+
flexDirection: "column",
|
|
317
|
+
alignItems: "flex-start",
|
|
318
|
+
gap: spacing[2],
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
{countText !== null && (
|
|
322
|
+
<Typography.Text strong data-testid="facets-footer-count">
|
|
323
|
+
{countText}
|
|
324
|
+
</Typography.Text>
|
|
325
|
+
)}
|
|
326
|
+
{props.activeFilters > 0 && (
|
|
327
|
+
<Button
|
|
328
|
+
style={FACET_CLEAR}
|
|
329
|
+
onClick={props.clearAll}
|
|
330
|
+
data-analytics="none"
|
|
331
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
332
|
+
data-testid="facets-clear-all"
|
|
333
|
+
>
|
|
334
|
+
{t(SEARCH_I18N_KEYS.facetsClearAll, { count: props.activeFilters })}
|
|
335
|
+
</Button>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
186
339
|
}
|
|
187
340
|
|
|
188
341
|
/** The category constraint: the host's control, or the door out of it. */
|
|
@@ -374,6 +527,10 @@ function skippedNames(
|
|
|
374
527
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
375
528
|
const t = useT();
|
|
376
529
|
const { state } = useSearchState();
|
|
530
|
+
// The panel-search's text. COMPONENT state on purpose: it narrows how the
|
|
531
|
+
// panel is drawn, never what the search is, so it must not survive into a
|
|
532
|
+
// shared link the way everything in `useSearchState` does.
|
|
533
|
+
const [filterQuery, setFilterQuery] = useState("");
|
|
377
534
|
|
|
378
535
|
return (
|
|
379
536
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -428,7 +585,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
428
585
|
{props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
|
|
429
586
|
</Typography.Title>
|
|
430
587
|
)}
|
|
431
|
-
{
|
|
588
|
+
{/* With the footer bar on, clear-all lives THERE — beside the
|
|
589
|
+
count it acts on — and drawing it here too would be two
|
|
590
|
+
identical exits one panel apart. */}
|
|
591
|
+
{bag.activeFilters > 0 && props.footerBar !== true && (
|
|
432
592
|
<Button
|
|
433
593
|
style={FACET_CLEAR}
|
|
434
594
|
onClick={bag.clearAll}
|
|
@@ -487,7 +647,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
487
647
|
title={t(SEARCH_I18N_KEYS.facetsApproximate)}
|
|
488
648
|
/>
|
|
489
649
|
)}
|
|
490
|
-
{bag.skipped.length > 0 && (
|
|
650
|
+
{props.skippedNotice === true && bag.skipped.length > 0 && (
|
|
491
651
|
<Alert
|
|
492
652
|
type="warning"
|
|
493
653
|
showIcon
|
|
@@ -511,35 +671,103 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
511
671
|
/>
|
|
512
672
|
)}
|
|
513
673
|
>
|
|
514
|
-
{(groups) =>
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
674
|
+
{(groups) => {
|
|
675
|
+
// A group with no options is a heading with nothing under
|
|
676
|
+
// it. What is left in that state after `buildFacetGroups`
|
|
677
|
+
// learned to read the schema is the genuinely unanswerable
|
|
678
|
+
// case: a `ref_select` whose config is a bare pointer into
|
|
679
|
+
// a vocabulary this pair cannot read. A heading with no
|
|
680
|
+
// control under it names nothing, so it is not drawn.
|
|
681
|
+
const drawable = groups.filter(
|
|
682
|
+
(group) => group.options.length > 0
|
|
683
|
+
);
|
|
684
|
+
// Which groups OPEN — see the module note. Chosen groups are
|
|
685
|
+
// open unconditionally below; here the answer's evidence
|
|
686
|
+
// picks the rest: the top counted groups by coverage, and a
|
|
687
|
+
// group the server never counted sums to zero, so the wall
|
|
688
|
+
// of "not counted" rows starts as headers.
|
|
689
|
+
const openByEvidence = new Set(
|
|
690
|
+
drawable
|
|
691
|
+
.filter((group) => group.counted)
|
|
692
|
+
.map((group) => [group, facetCoverage(group)] as const)
|
|
693
|
+
.sort((a, b) => b[1] - a[1])
|
|
694
|
+
.slice(0, FACET_OPEN_GROUPS)
|
|
695
|
+
.map(([group]) => group.slug)
|
|
696
|
+
);
|
|
697
|
+
const searchable = drawable.length >= FACET_SEARCH_THRESHOLD;
|
|
698
|
+
const needle = searchable
|
|
699
|
+
? filterQuery.trim().toLowerCase()
|
|
700
|
+
: "";
|
|
701
|
+
const matches = (group: FacetGroup): boolean =>
|
|
702
|
+
group.label.toLowerCase().includes(needle) ||
|
|
703
|
+
group.options.some((option) =>
|
|
704
|
+
option.label.toLowerCase().includes(needle)
|
|
705
|
+
);
|
|
706
|
+
const listed =
|
|
707
|
+
needle === "" ? drawable : drawable.filter(matches);
|
|
708
|
+
return (
|
|
709
|
+
<Flex vertical gap={spacing[4]}>
|
|
710
|
+
{searchable && (
|
|
711
|
+
<Input
|
|
712
|
+
allowClear
|
|
713
|
+
value={filterQuery}
|
|
714
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsSearch)}
|
|
715
|
+
aria-label={t(SEARCH_I18N_KEYS.facetsSearch)}
|
|
716
|
+
data-testid="facets-search"
|
|
717
|
+
onChange={(event) => {
|
|
718
|
+
setFilterQuery(event.target.value);
|
|
719
|
+
}}
|
|
720
|
+
/>
|
|
721
|
+
)}
|
|
722
|
+
{/* The query missed. The groups are still there — one
|
|
723
|
+
cleared box away — so this is the panel-search's empty
|
|
724
|
+
state, not the panel's. */}
|
|
725
|
+
{needle !== "" && listed.length === 0 && (
|
|
726
|
+
<EmptyState
|
|
727
|
+
compact
|
|
728
|
+
title={t(SEARCH_I18N_KEYS.facetsSearchEmpty)}
|
|
729
|
+
testId="facets-search-empty"
|
|
730
|
+
/>
|
|
731
|
+
)}
|
|
732
|
+
{/* Each group draws itself the way its own schema says:
|
|
733
|
+
pills for a single-choice facet, indented children for a
|
|
734
|
+
hierarchical one, a fold for a long one. The panel does
|
|
735
|
+
not decide — `facetGroupShape` reads the same config keys
|
|
736
|
+
the attributes editor reads, so a facet cannot look one
|
|
737
|
+
way here and another way in the composer. */}
|
|
738
|
+
{/* The key changes with the query's presence ON PURPOSE:
|
|
739
|
+
`defaultOpen` is an initial value, and a group the
|
|
740
|
+
panel-search matched has to render OPEN — a hit behind
|
|
741
|
+
a closed header is not an answer. Remounting is the
|
|
742
|
+
honest way to re-ask the question; the person's own
|
|
743
|
+
opens and closes come back when the box clears. */}
|
|
744
|
+
{listed.map((group) => (
|
|
531
745
|
<FacetGroupControl
|
|
532
|
-
key={group.slug}
|
|
746
|
+
key={needle === "" ? group.slug : `${group.slug}:match`}
|
|
533
747
|
group={group}
|
|
534
748
|
onToggle={bag.toggle}
|
|
749
|
+
collapsible
|
|
750
|
+
defaultOpen={
|
|
751
|
+
needle !== "" ||
|
|
752
|
+
group.selected.length > 0 ||
|
|
753
|
+
openByEvidence.has(group.slug)
|
|
754
|
+
}
|
|
535
755
|
/>
|
|
536
756
|
))}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
757
|
+
<Typography.Text type="secondary">
|
|
758
|
+
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
759
|
+
</Typography.Text>
|
|
760
|
+
</Flex>
|
|
761
|
+
);
|
|
762
|
+
}}
|
|
542
763
|
</LoadList>
|
|
764
|
+
|
|
765
|
+
{props.footerBar === true && (
|
|
766
|
+
<RailFooterBar
|
|
767
|
+
activeFilters={bag.activeFilters}
|
|
768
|
+
clearAll={bag.clearAll}
|
|
769
|
+
/>
|
|
770
|
+
)}
|
|
543
771
|
</Flex>
|
|
544
772
|
);
|
|
545
773
|
}}
|
|
@@ -133,6 +133,7 @@ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
|
133
133
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
134
134
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
135
135
|
import type { RangeGroup } from "../state/ranges.js";
|
|
136
|
+
import { facetCoverage } from "../state/facets.js";
|
|
136
137
|
import type { FacetGroup } from "../state/facets.js";
|
|
137
138
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
138
139
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
@@ -242,10 +243,48 @@ export function orderChipFilters(
|
|
|
242
243
|
return [...specs].sort((a, b) => {
|
|
243
244
|
const applied = Number(specApplied(b)) - Number(specApplied(a));
|
|
244
245
|
if (applied !== 0) return applied;
|
|
245
|
-
|
|
246
|
+
const band = CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
|
|
247
|
+
if (band !== 0) return band;
|
|
248
|
+
// WITHIN the counted-facet band: coverage — the answer's own evidence of
|
|
249
|
+
// which axes this corpus actually fills (D16 reopen: an imported
|
|
250
|
+
// catalogue gave the phones leaf option tables for its wholesale
|
|
251
|
+
// plumbing, and schema order put them ahead of the brand). A group the
|
|
252
|
+
// server did not count sums to zero and trails every counted one; ties
|
|
253
|
+
// keep the authored order because the sort is stable.
|
|
254
|
+
if (a.band === "facet" && b.band === "facet") {
|
|
255
|
+
return facetCoverage(b.facet) - facetCoverage(a.facet);
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
246
258
|
});
|
|
247
259
|
}
|
|
248
260
|
|
|
261
|
+
/**
|
|
262
|
+
* How many banded chips the row draws before the "more" door — enough for the
|
|
263
|
+
* axes with real evidence behind them, small enough that the tail of a
|
|
264
|
+
* 44-axis cars leaf lives in the panel instead of in four flicks. A host
|
|
265
|
+
* passes `maxRowChips` to move it, or `null` to disable the door.
|
|
266
|
+
*/
|
|
267
|
+
export const CHIP_ROW_CAP = 8;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The visible row and what the door owes: the first `max` specs — and EVERY
|
|
271
|
+
* applied one, however many, because a constraint on screen must keep the
|
|
272
|
+
* control that removes it (the same rule the barren filter follows). The
|
|
273
|
+
* overflow count is what the door chip prints; the chips behind it are not
|
|
274
|
+
* deleted, they are the panel's — one tap behind the door itself.
|
|
275
|
+
*/
|
|
276
|
+
export function capChipRow(
|
|
277
|
+
specs: readonly ChipSpec[],
|
|
278
|
+
max: number | null
|
|
279
|
+
): { readonly visible: readonly ChipSpec[]; readonly overflow: number } {
|
|
280
|
+
if (max === null || specs.length <= max) {
|
|
281
|
+
return { visible: specs, overflow: 0 };
|
|
282
|
+
}
|
|
283
|
+
const applied = specs.filter(specApplied).length;
|
|
284
|
+
const cut = Math.max(max, applied);
|
|
285
|
+
return { visible: specs.slice(0, cut), overflow: specs.length - cut };
|
|
286
|
+
}
|
|
287
|
+
|
|
249
288
|
export interface FilterChipsProps {
|
|
250
289
|
/** The category's feature schema — the source of option labels, of which
|
|
251
290
|
* slugs get a range chip, of which slugs are choosable at all, and of how
|
|
@@ -295,6 +334,13 @@ export interface FilterChipsProps {
|
|
|
295
334
|
/** Open the whole panel — the leading chip's action. The page owns that
|
|
296
335
|
* sheet, because the page is the surface it covers. */
|
|
297
336
|
readonly onOpenAll: () => void;
|
|
337
|
+
/**
|
|
338
|
+
* The row's chip budget before the "more" door — see {@link CHIP_ROW_CAP}
|
|
339
|
+
* (the default). `null` draws every chip, door-less, as the row did before
|
|
340
|
+
* it was capped. Applied filters never count against the budget's loss:
|
|
341
|
+
* they are always drawn.
|
|
342
|
+
*/
|
|
343
|
+
readonly maxRowChips?: number | null;
|
|
298
344
|
/**
|
|
299
345
|
* Draw the location chip. Default `true`.
|
|
300
346
|
*
|
|
@@ -437,7 +483,11 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
437
483
|
// need. Applied ones stay: a constraint always keeps the control that
|
|
438
484
|
// removes it.
|
|
439
485
|
const barren = bag.counted.length > 0 && bag.candidates === 0;
|
|
440
|
-
const
|
|
486
|
+
const orderedAll = orderChipFilters(ranges, groups, { barren });
|
|
487
|
+
const { visible: ordered, overflow } = capChipRow(
|
|
488
|
+
orderedAll,
|
|
489
|
+
props.maxRowChips === undefined ? CHIP_ROW_CAP : props.maxRowChips
|
|
490
|
+
);
|
|
441
491
|
|
|
442
492
|
const geo = state.geo;
|
|
443
493
|
const showGeoChip =
|
|
@@ -569,6 +619,23 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
569
619
|
</Button>
|
|
570
620
|
)
|
|
571
621
|
)}
|
|
622
|
+
|
|
623
|
+
{/* The "more" door (D16): the capped tail is not deleted, it is one
|
|
624
|
+
tap away — this opens the SAME full panel the leading circle
|
|
625
|
+
does, where every cut control lives whole. The count keeps the
|
|
626
|
+
door honest about how much it is standing in front of. */}
|
|
627
|
+
{overflow > 0 && (
|
|
628
|
+
<Button
|
|
629
|
+
style={CHIP}
|
|
630
|
+
shape="round"
|
|
631
|
+
data-testid="search-chips-overflow"
|
|
632
|
+
data-analytics="none"
|
|
633
|
+
data-analytics-reason="opening the filter sheet is a read, not a flow step"
|
|
634
|
+
onClick={props.onOpenAll}
|
|
635
|
+
>
|
|
636
|
+
{t(SEARCH_I18N_KEYS.filtersChipOverflow, { count: overflow })}
|
|
637
|
+
</Button>
|
|
638
|
+
)}
|
|
572
639
|
</div>
|
|
573
640
|
|
|
574
641
|
{/* One sheet per chip, rendered only for the open one: a dozen mounted
|
|
@@ -139,6 +139,15 @@ const RAIL: CSSProperties = {
|
|
|
139
139
|
maxHeight: "100dvh",
|
|
140
140
|
overflowY: "auto",
|
|
141
141
|
overscrollBehavior: "contain",
|
|
142
|
+
// The inner scroll must be VISIBLE. On overlay-scrollbar platforms (every
|
|
143
|
+
// Mac by default, most phones) an `overflow-y: auto` column shows no
|
|
144
|
+
// scrollbar until a pointer happens to scroll INSIDE it — so a rail taller
|
|
145
|
+
// than the window is indistinguishable from a rail that ends at the fold,
|
|
146
|
+
// and the walker measured 5717px of panel whose tail nothing signposted.
|
|
147
|
+
// A thin, always-there scrollbar is the sign there is more; the stable
|
|
148
|
+
// gutter keeps the panel's right edge from jumping when it appears.
|
|
149
|
+
scrollbarWidth: "thin",
|
|
150
|
+
scrollbarGutter: "stable",
|
|
142
151
|
// Room for the focus ring of the last control against the scroll edge.
|
|
143
152
|
paddingBlockEnd: spacing[2],
|
|
144
153
|
};
|
|
@@ -197,6 +206,9 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
197
206
|
* coordinate, with or without it.
|
|
198
207
|
*/
|
|
199
208
|
readonly geoLabel?: ReactNode;
|
|
209
|
+
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
210
|
+
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
211
|
+
readonly skippedNotice?: boolean;
|
|
200
212
|
/**
|
|
201
213
|
* Open the search centred where the visitor is, when the URL says nothing
|
|
202
214
|
* about location — see {@link SearchStateProviderProps.defaultGeo} for the
|
|
@@ -323,6 +335,7 @@ interface SearchPageBodyProps {
|
|
|
323
335
|
readonly categoryLabel?: ReactNode;
|
|
324
336
|
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
325
337
|
readonly geoLabel?: ReactNode;
|
|
338
|
+
readonly skippedNotice?: boolean;
|
|
326
339
|
readonly footer?: ReactNode;
|
|
327
340
|
readonly filtersHeader?: ReactNode;
|
|
328
341
|
readonly resultsHeader?: ReactNode;
|
|
@@ -420,9 +433,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
420
433
|
a hole, just a smaller one. */}
|
|
421
434
|
{/* In the sheet the dialog's own title already says "Filters"; the panel
|
|
422
435
|
repeating it printed the word twice, one line apart. */}
|
|
436
|
+
{/* Per layout: the sheet gets no second "Filters" title (the dialog owns
|
|
437
|
+
one) and no footer bar — its own "Show N results" footer is already
|
|
438
|
+
the count AND the exit. The rail gets the sticky footer bar: desktop
|
|
439
|
+
filters apply instantly, and without it the only feedback was a
|
|
440
|
+
result count scrolled out of sight above the fold. */}
|
|
423
441
|
{filtersEmpty ? null : (
|
|
424
442
|
<FacetPanelPane
|
|
425
|
-
{...(layout === "sheet" ? { heading: null } : {})}
|
|
443
|
+
{...(layout === "sheet" ? { heading: null } : { footerBar: true })}
|
|
426
444
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
427
445
|
{...(props.renderEmptyExits !== undefined
|
|
428
446
|
? { renderEmptyExits: props.renderEmptyExits }
|
|
@@ -437,6 +455,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
437
455
|
? { renderGeoFilter: props.renderGeoFilter }
|
|
438
456
|
: {})}
|
|
439
457
|
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
458
|
+
{...(props.skippedNotice !== undefined
|
|
459
|
+
? { skippedNotice: props.skippedNotice }
|
|
460
|
+
: {})}
|
|
440
461
|
/>
|
|
441
462
|
)}
|
|
442
463
|
</Flex>
|
|
@@ -611,6 +632,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
611
632
|
categoryLabel,
|
|
612
633
|
renderGeoFilter,
|
|
613
634
|
geoLabel,
|
|
635
|
+
skippedNotice,
|
|
614
636
|
defaultGeo,
|
|
615
637
|
footer,
|
|
616
638
|
filtersHeader,
|
|
@@ -644,6 +666,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
644
666
|
{...(categoryLabel !== undefined ? { categoryLabel } : {})}
|
|
645
667
|
{...(renderGeoFilter !== undefined ? { renderGeoFilter } : {})}
|
|
646
668
|
{...(geoLabel !== undefined ? { geoLabel } : {})}
|
|
669
|
+
{...(skippedNotice !== undefined ? { skippedNotice } : {})}
|
|
647
670
|
{...(footer !== undefined ? { footer } : {})}
|
|
648
671
|
{...(filtersHeader !== undefined ? { filtersHeader } : {})}
|
|
649
672
|
{...(resultsHeader !== undefined ? { resultsHeader } : {})}
|
|
@@ -70,20 +70,33 @@ export type SearchResultsRenderer = (
|
|
|
70
70
|
* width of whatever it was dropped into — the visual pass measured a 2560px
|
|
71
71
|
* pane with four cards floating at the top of it and a status row 1350px from
|
|
72
72
|
* the buttons that act on it (class C-NOMAXW).
|
|
73
|
+
*
|
|
74
|
+
* 1400 and not the 1120 it started at: a card GRID is not prose — its rows
|
|
75
|
+
* have no line to lose the thread of — so its measure is the fleet's widest
|
|
76
|
+
* content column, not a paragraph's. At 1120 the cap quietly overrode the
|
|
77
|
+
* grid's own 260px floor (a 1440px desktop drew three 363px cards inside a
|
|
78
|
+
* 1392px content column, the very hole the floor was lowered to fill); at
|
|
79
|
+
* 1400 the floor decides — five columns open, four beside the filter rail —
|
|
80
|
+
* and the 2560px pane the constant was written against is still capped.
|
|
73
81
|
*/
|
|
74
|
-
export const RESULTS_MAX_WIDTH =
|
|
82
|
+
export const RESULTS_MAX_WIDTH = 1400;
|
|
75
83
|
|
|
76
84
|
/**
|
|
77
|
-
* The results grid. `auto-fill` + `minmax(
|
|
78
|
-
* each at least a readable card and never wider than its share —
|
|
79
|
-
* on a 1400px desktop is four columns, not four full-bleed rows, and the same
|
|
85
|
+
* The results grid. `auto-fill` + `minmax(260px, 1fr)`: as many columns as fit,
|
|
86
|
+
* each at least a readable card and never wider than its share — and the same
|
|
80
87
|
* declaration collapses to one column on a phone with no breakpoint to
|
|
81
|
-
* maintain.
|
|
82
|
-
*
|
|
88
|
+
* maintain.
|
|
89
|
+
*
|
|
90
|
+
* The floor was 280px and is 260 deliberately: at a 1400px content measure the
|
|
91
|
+
* grid now draws five columns instead of three wide ones, and beside the 280px
|
|
92
|
+
* filter rail, four. The default card's own minimum — title, price and
|
|
93
|
+
* location each on their own line — still fits at 260; the 20px the old floor
|
|
94
|
+
* bought went into card whitespace, not into legibility, and cost a whole
|
|
95
|
+
* column of listings on the reference desktop.
|
|
83
96
|
*/
|
|
84
97
|
const RESULTS_GRID: CSSProperties = {
|
|
85
98
|
display: "grid",
|
|
86
|
-
gridTemplateColumns: "repeat(auto-fill, minmax(
|
|
99
|
+
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
|
|
87
100
|
gap: spacing[3],
|
|
88
101
|
alignItems: "stretch",
|
|
89
102
|
};
|
package/src/default/index.ts
CHANGED
package/src/i18n/es.ts
CHANGED
|
@@ -87,6 +87,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
87
87
|
"search.facets.range_to_aria": "{feature}, hasta",
|
|
88
88
|
"search.facets.show_all": "Ver todos ({count})",
|
|
89
89
|
"search.facets.show_less": "Ver menos",
|
|
90
|
+
"search.facets.search": "Buscar un filtro",
|
|
91
|
+
"search.facets.search_empty": "Ningún filtro coincide con esto",
|
|
92
|
+
"search.facets.match_count.one": "{count} anuncio coincide",
|
|
93
|
+
"search.facets.match_count.other": "{count} anuncios coinciden",
|
|
90
94
|
"search.facets.range_invalid":
|
|
91
95
|
"«Desde» es mayor que «hasta», así que nada podría coincidir. Intercámbialos para aplicar el rango.",
|
|
92
96
|
|
|
@@ -101,6 +105,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
101
105
|
"search.filters.all": "Todos los filtros",
|
|
102
106
|
"search.filters.short": "Filtros",
|
|
103
107
|
"search.filters.chip_more": ", +{count}",
|
|
108
|
+
"search.filters.chips_overflow": "Más · {count}",
|
|
104
109
|
|
|
105
110
|
"search.empty.exits_title": "Prueba a ampliar la búsqueda",
|
|
106
111
|
"search.empty.up_a_level": "Subir un nivel",
|