@stapel/search-react 0.16.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/dist/api/generated/schema.d.ts +18 -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 +25 -38
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +157 -6
- 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.map +1 -1
- package/dist/default/SearchPage.js +10 -1
- 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/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +4 -1
- package/dist/default/SortSelect.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/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 +19 -0
- 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 +18 -10
- package/src/default/FacetGroupControl.tsx +217 -60
- package/src/default/FacetPanelPane.tsx +226 -25
- package/src/default/FilterChips.tsx +69 -2
- package/src/default/SearchPage.tsx +15 -1
- package/src/default/SearchResultsPane.tsx +20 -7
- package/src/default/SortSelect.tsx +3 -1
- package/src/default/index.ts +2 -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 +19 -0
|
@@ -35,17 +35,44 @@
|
|
|
35
35
|
* than a silent absence — and, in every build, any constraint the URL already
|
|
36
36
|
* carries gets a control that REMOVES it. A shared link that narrows to a
|
|
37
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.
|
|
38
62
|
*/
|
|
63
|
+
import { useState } from "react";
|
|
39
64
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
40
65
|
import {
|
|
41
66
|
Alert,
|
|
42
67
|
Button,
|
|
43
68
|
Divider,
|
|
44
69
|
Flex,
|
|
70
|
+
Input,
|
|
45
71
|
InputNumber,
|
|
46
72
|
Typography,
|
|
73
|
+
theme,
|
|
47
74
|
} from "antd";
|
|
48
|
-
import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
|
|
75
|
+
import { SlotPlaceholder, isDevBuild, useT, useTPlural } from "@stapel/core";
|
|
49
76
|
import {
|
|
50
77
|
EmptyState,
|
|
51
78
|
ErrorAlert,
|
|
@@ -59,6 +86,9 @@ import type { SearchGeo } from "../api/types.js";
|
|
|
59
86
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
60
87
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
61
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";
|
|
62
92
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
63
93
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
64
94
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -123,6 +153,21 @@ const FACET_CLEAR: CSSProperties = {
|
|
|
123
153
|
textAlign: "start",
|
|
124
154
|
};
|
|
125
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
|
+
|
|
126
171
|
/** What a host's category control is handed. */
|
|
127
172
|
export interface CategoryFilterSlotProps {
|
|
128
173
|
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
@@ -210,6 +255,87 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
210
255
|
* on a staging surface. Nothing else changes with it.
|
|
211
256
|
*/
|
|
212
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
|
+
);
|
|
213
339
|
}
|
|
214
340
|
|
|
215
341
|
/** The category constraint: the host's control, or the door out of it. */
|
|
@@ -401,6 +527,10 @@ function skippedNames(
|
|
|
401
527
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
402
528
|
const t = useT();
|
|
403
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("");
|
|
404
534
|
|
|
405
535
|
return (
|
|
406
536
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -455,7 +585,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
455
585
|
{props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
|
|
456
586
|
</Typography.Title>
|
|
457
587
|
)}
|
|
458
|
-
{
|
|
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 && (
|
|
459
592
|
<Button
|
|
460
593
|
style={FACET_CLEAR}
|
|
461
594
|
onClick={bag.clearAll}
|
|
@@ -538,35 +671,103 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
538
671
|
/>
|
|
539
672
|
)}
|
|
540
673
|
>
|
|
541
|
-
{(groups) =>
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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) => (
|
|
558
745
|
<FacetGroupControl
|
|
559
|
-
key={group.slug}
|
|
746
|
+
key={needle === "" ? group.slug : `${group.slug}:match`}
|
|
560
747
|
group={group}
|
|
561
748
|
onToggle={bag.toggle}
|
|
749
|
+
collapsible
|
|
750
|
+
defaultOpen={
|
|
751
|
+
needle !== "" ||
|
|
752
|
+
group.selected.length > 0 ||
|
|
753
|
+
openByEvidence.has(group.slug)
|
|
754
|
+
}
|
|
562
755
|
/>
|
|
563
756
|
))}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
757
|
+
<Typography.Text type="secondary">
|
|
758
|
+
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
759
|
+
</Typography.Text>
|
|
760
|
+
</Flex>
|
|
761
|
+
);
|
|
762
|
+
}}
|
|
569
763
|
</LoadList>
|
|
764
|
+
|
|
765
|
+
{props.footerBar === true && (
|
|
766
|
+
<RailFooterBar
|
|
767
|
+
activeFilters={bag.activeFilters}
|
|
768
|
+
clearAll={bag.clearAll}
|
|
769
|
+
/>
|
|
770
|
+
)}
|
|
570
771
|
</Flex>
|
|
571
772
|
);
|
|
572
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
|
};
|
|
@@ -424,9 +433,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
424
433
|
a hole, just a smaller one. */}
|
|
425
434
|
{/* In the sheet the dialog's own title already says "Filters"; the panel
|
|
426
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. */}
|
|
427
441
|
{filtersEmpty ? null : (
|
|
428
442
|
<FacetPanelPane
|
|
429
|
-
{...(layout === "sheet" ? { heading: null } : {})}
|
|
443
|
+
{...(layout === "sheet" ? { heading: null } : { footerBar: true })}
|
|
430
444
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
431
445
|
{...(props.renderEmptyExits !== undefined
|
|
432
446
|
? { renderEmptyExits: props.renderEmptyExits }
|
|
@@ -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
|
};
|
|
@@ -136,7 +136,9 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
return (
|
|
139
|
-
|
|
139
|
+
// `annotate`: the gate judges ONE option, not the control. Suppressing
|
|
140
|
+
// the select would take away every sort, which is not what is blocked.
|
|
141
|
+
<GatedControl gate={distance} whenBlocked="annotate" testId="search-sort-gate">
|
|
140
142
|
{(bind) => (
|
|
141
143
|
<Flex gap={spacing[2]} align="center">
|
|
142
144
|
<Typography.Text type="secondary" aria-hidden="true">
|
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",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -138,6 +138,22 @@ export const SEARCH_I18N_KEYS = {
|
|
|
138
138
|
* tail is longer than one row. */
|
|
139
139
|
facetsShowAll: "search.facets.show_all",
|
|
140
140
|
facetsShowLess: "search.facets.show_less",
|
|
141
|
+
/** The box that searches the FILTER PANEL itself — drawn from six groups
|
|
142
|
+
* up, because a panel a person can no longer scan is a panel they have to
|
|
143
|
+
* query. It narrows presentation only and never touches the URL. */
|
|
144
|
+
facetsSearch: "search.facets.search",
|
|
145
|
+
/** Said where the groups were when the panel-search matches none of them.
|
|
146
|
+
* Distinct from `facetsEmpty`, which says the SEARCH offers no filters —
|
|
147
|
+
* here the filters exist and the query missed. */
|
|
148
|
+
facetsSearchEmpty: "search.facets.search_empty",
|
|
149
|
+
/**
|
|
150
|
+
* The rail's sticky footer: what the applied filters LEFT — "N listings
|
|
151
|
+
* match". A PLURAL FAMILY: it counts a noun in words. Desktop filters
|
|
152
|
+
* apply instantly, so this is feedback, not an apply button; the floor
|
|
153
|
+
* case (`at_least`) reuses `resultsCountAtLeast` rather than growing a
|
|
154
|
+
* second family for the same number.
|
|
155
|
+
*/
|
|
156
|
+
facetsMatchCount: "search.facets.match_count",
|
|
141
157
|
|
|
142
158
|
// ── the filter panel as a whole (phone sheet + host slots) ───────────────
|
|
143
159
|
filtersOpen: "search.filters.open",
|
|
@@ -161,6 +177,9 @@ export const SEARCH_I18N_KEYS = {
|
|
|
161
177
|
/** A chip filtering on more than one value: "Bosch, +2". Not a plural
|
|
162
178
|
* family — nothing is being counted in words. */
|
|
163
179
|
filtersChipMore: "search.filters.chip_more",
|
|
180
|
+
/** The chip standing in for the row's capped tail — opens the full panel.
|
|
181
|
+
* `{count}` is how many chips it hides (D16). */
|
|
182
|
+
filtersChipOverflow: "search.filters.chips_overflow",
|
|
164
183
|
|
|
165
184
|
// ── the way OUT of a search that found nothing ───────────────────────────
|
|
166
185
|
/** Caption above the exit buttons. Not a second "nothing found" — that
|
|
@@ -308,6 +327,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
308
327
|
SEARCH_I18N_KEYS.filtersShowCount,
|
|
309
328
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
310
329
|
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
330
|
+
SEARCH_I18N_KEYS.facetsMatchCount,
|
|
311
331
|
];
|
|
312
332
|
|
|
313
333
|
/**
|
|
@@ -392,6 +412,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
392
412
|
"search.facets.range_to_aria": "{feature}, up to",
|
|
393
413
|
"search.facets.show_all": "Show all ({count})",
|
|
394
414
|
"search.facets.show_less": "Show fewer",
|
|
415
|
+
"search.facets.search": "Find a filter",
|
|
416
|
+
"search.facets.search_empty": "No filter matches this",
|
|
417
|
+
"search.facets.match_count.one": "{count} listing matches",
|
|
418
|
+
"search.facets.match_count.other": "{count} listings match",
|
|
395
419
|
"search.facets.range_invalid":
|
|
396
420
|
"“From” is larger than “to”, so nothing could match. Swap them to apply this range.",
|
|
397
421
|
|
|
@@ -406,6 +430,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
406
430
|
"search.filters.all": "All filters",
|
|
407
431
|
"search.filters.short": "Filters",
|
|
408
432
|
"search.filters.chip_more": ", +{count}",
|
|
433
|
+
"search.filters.chips_overflow": "More · {count}",
|
|
409
434
|
|
|
410
435
|
"search.empty.exits_title": "Try widening the search",
|
|
411
436
|
"search.empty.up_a_level": "Go up a level",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -112,6 +112,12 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
112
112
|
"search.facets.range_to_aria": "{feature}, до",
|
|
113
113
|
"search.facets.show_all": "Показать все ({count})",
|
|
114
114
|
"search.facets.show_less": "Свернуть",
|
|
115
|
+
"search.facets.search": "Найти фильтр",
|
|
116
|
+
"search.facets.search_empty": "Среди фильтров такого нет",
|
|
117
|
+
"search.facets.match_count.one": "Найдено {count} объявление",
|
|
118
|
+
"search.facets.match_count.few": "Найдено {count} объявления",
|
|
119
|
+
"search.facets.match_count.many": "Найдено {count} объявлений",
|
|
120
|
+
"search.facets.match_count.other": "Найдено {count} объявления",
|
|
115
121
|
"search.facets.range_invalid":
|
|
116
122
|
"«От» больше, чем «до», — под такой диапазон ничего не подойдёт. Поменяйте значения местами.",
|
|
117
123
|
|
|
@@ -130,6 +136,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
130
136
|
"search.filters.all": "Все фильтры",
|
|
131
137
|
"search.filters.short": "Фильтры",
|
|
132
138
|
"search.filters.chip_more": ", +{count}",
|
|
139
|
+
"search.filters.chips_overflow": "Ещё {count}",
|
|
133
140
|
|
|
134
141
|
"search.empty.exits_title": "Попробуйте расширить поиск",
|
|
135
142
|
"search.empty.up_a_level": "Подняться на уровень выше",
|
package/src/state/facets.ts
CHANGED
|
@@ -128,6 +128,25 @@ export interface FacetGroup {
|
|
|
128
128
|
readonly selected: readonly string[];
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* How many candidate documents carry ANY value of this axis — the evidence
|
|
133
|
+
* that people actually fill this field in. Uncounted options are `null`
|
|
134
|
+
* (never `0` — different sentences) and contribute nothing, so a group the
|
|
135
|
+
* server did not count sums to zero.
|
|
136
|
+
*
|
|
137
|
+
* Two surfaces rank by it, for the same reason: the chip row orders its
|
|
138
|
+
* counted band by coverage (D16 reopen — an imported catalogue gave the
|
|
139
|
+
* phones leaf option tables for its wholesale plumbing, and schema order put
|
|
140
|
+
* them ahead of the brand), and the filter panel opens its top-coverage
|
|
141
|
+
* groups and collapses the rest (the 5717px rail). One definition, or the
|
|
142
|
+
* two surfaces drift into two opinions about which axes matter.
|
|
143
|
+
*/
|
|
144
|
+
export function facetCoverage(group: FacetGroup): number {
|
|
145
|
+
let total = 0;
|
|
146
|
+
for (const option of group.options) total += option.count ?? 0;
|
|
147
|
+
return total;
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
export interface BuildFacetGroupsInput {
|
|
132
151
|
/** The envelope's `facets`: `{slug: {value: count}}`. */
|
|
133
152
|
readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
|