@stapel/search-react 0.21.0 → 0.23.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 +141 -0
- package/MODULE.md +12 -0
- package/README.md +143 -2
- package/dist/api/types.d.ts +37 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +53 -5
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +189 -8
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +41 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +72 -18
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +2 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +37 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +120 -0
- package/dist/default/PartitionChips.js.map +1 -0
- package/dist/default/PopularValues.d.ts +58 -0
- package/dist/default/PopularValues.d.ts.map +1 -0
- package/dist/default/PopularValues.js +46 -0
- package/dist/default/PopularValues.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +65 -11
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +49 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +43 -2
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +7 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +8 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/useFacetLabels.js +7 -7
- package/dist/headless/useFacetLabels.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +52 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +62 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +90 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +216 -15
- package/dist/state/facets.js.map +1 -1
- package/dist/state/ranges.d.ts +22 -0
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +30 -0
- package/dist/state/ranges.js.map +1 -1
- package/dist/state/translit.d.ts +58 -0
- package/dist/state/translit.d.ts.map +1 -0
- package/dist/state/translit.js +115 -0
- package/dist/state/translit.js.map +1 -0
- package/llms.txt +3 -1
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +42 -3
- package/src/default/FacetGroupControl.tsx +339 -9
- package/src/default/FacetPanelPane.tsx +131 -19
- package/src/default/FilterChips.tsx +5 -2
- package/src/default/PartitionChips.tsx +204 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/RangeFilterRow.tsx +116 -1
- package/src/default/SearchPage.tsx +61 -2
- package/src/default/index.ts +27 -2
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +9 -0
- package/src/index.ts +13 -0
- package/src/state/facets.ts +259 -27
- package/src/state/ranges.ts +44 -0
- package/src/state/translit.ts +113 -0
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* 3. **A 60-option facet is not a list, it is a wall.** Every catalogue has
|
|
20
20
|
* one — brand, model, city — and printing all of it pushes every group
|
|
21
21
|
* under it off the screen.
|
|
22
|
+
* 4. **A vocabulary is not a long list, it is a DICTIONARY.** 418 car makes
|
|
23
|
+
* behind "Show all (418)" is a control whose only mode is "read all of
|
|
24
|
+
* it": the busiest values, a box, and the rest reachable by typing is the
|
|
25
|
+
* only shape that answers "I want a Toyota" in one gesture. See
|
|
26
|
+
* {@link isDictionaryFacet}.
|
|
22
27
|
*
|
|
23
28
|
* ── The presentation is DERIVED, never configured here ────────────────────
|
|
24
29
|
*
|
|
@@ -59,12 +64,17 @@
|
|
|
59
64
|
*/
|
|
60
65
|
import { useState } from "react";
|
|
61
66
|
import type { CSSProperties, ReactElement } from "react";
|
|
62
|
-
import { Button, Checkbox, Flex, Typography } from "antd";
|
|
67
|
+
import { Button, Checkbox, Flex, Input, Typography } from "antd";
|
|
63
68
|
import { useT } from "@stapel/core";
|
|
64
|
-
import { radii, spacing } from "@stapel/tokens";
|
|
65
|
-
import {
|
|
69
|
+
import { controls, cssVar, radii, spacing } from "@stapel/tokens";
|
|
70
|
+
import {
|
|
71
|
+
VOCABULARY_BACKED_TYPES,
|
|
72
|
+
featureConfig,
|
|
73
|
+
featureType,
|
|
74
|
+
} from "@stapel/attributes-react";
|
|
66
75
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
67
76
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
77
|
+
import { translitPrefixMatch } from "../state/translit.js";
|
|
68
78
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
69
79
|
|
|
70
80
|
/**
|
|
@@ -77,8 +87,19 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
|
77
87
|
*/
|
|
78
88
|
export const FACET_VISIBLE_OPTIONS = 8;
|
|
79
89
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
90
|
+
/**
|
|
91
|
+
* From how many values a group stops being a list and becomes a DICTIONARY —
|
|
92
|
+
* the same eight, because it is the same fold: past it the group is drawn as
|
|
93
|
+
* its busiest values plus a box that searches the rest.
|
|
94
|
+
*/
|
|
95
|
+
export const FACET_DICTIONARY_THRESHOLD: number = FACET_VISIBLE_OPTIONS;
|
|
96
|
+
|
|
97
|
+
/** The four shapes a facet group takes. */
|
|
98
|
+
export type FacetGroupShape =
|
|
99
|
+
| "segmented"
|
|
100
|
+
| "nested"
|
|
101
|
+
| "checkbox"
|
|
102
|
+
| "dictionary";
|
|
82
103
|
|
|
83
104
|
/** The option rows of a group, already nested where the schema nests them. */
|
|
84
105
|
export interface FacetOptionNode {
|
|
@@ -126,16 +147,61 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
|
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
/**
|
|
129
|
-
*
|
|
150
|
+
* Is this group a DICTIONARY — a vocabulary's level, too long to scroll?
|
|
151
|
+
*
|
|
152
|
+
* Two ways to be one, because the schema is an optional slot and the live
|
|
153
|
+
* case is the one where it is empty:
|
|
154
|
+
*
|
|
155
|
+
* - the def types the slug `ref_select`/`ref_hierarchical_select`, i.e. its
|
|
156
|
+
* config is a POINTER into a vocabulary and there was never an option
|
|
157
|
+
* table to draw;
|
|
158
|
+
* - there is NO def at all and the answer came back with more values than a
|
|
159
|
+
* fold. At a live classified's cars branch the storefront passed an empty
|
|
160
|
+
* feature list, so the 418 makes arrived as an unnamed, untyped group of
|
|
161
|
+
* 418 checkboxes behind "Show all (418)". A box is the only control that
|
|
162
|
+
* answers that, and refusing to draw one because the schema is missing
|
|
163
|
+
* punishes the buyer for the wiring.
|
|
164
|
+
*
|
|
165
|
+
* Either way it takes more than {@link FACET_DICTIONARY_THRESHOLD} EVIDENCE
|
|
166
|
+
* buckets — values the answer actually counted. A zero-filled option table or
|
|
167
|
+
* a schema-only tail is a list a person can already read, and a box over it
|
|
168
|
+
* would search for values no document carries.
|
|
169
|
+
*/
|
|
170
|
+
export function isDictionaryFacet(group: FacetGroup): boolean {
|
|
171
|
+
const buckets = group.options.filter(
|
|
172
|
+
(option) => option.count !== null && option.count > 0
|
|
173
|
+
).length;
|
|
174
|
+
if (buckets <= FACET_DICTIONARY_THRESHOLD) return false;
|
|
175
|
+
const feature = group.feature;
|
|
176
|
+
if (feature === undefined) return true;
|
|
177
|
+
const type = featureType(feature);
|
|
178
|
+
return type !== undefined && VOCABULARY_BACKED_TYPES.includes(type);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Which of the four shapes a group takes.
|
|
183
|
+
*
|
|
184
|
+
* Order matters, and it changed in one place after a live measurement:
|
|
130
185
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
186
|
+
* - a hierarchical facet is nested even when it is single-choice, because
|
|
187
|
+
* losing the tree costs more than losing the pills;
|
|
188
|
+
* - a DICTIONARY outranks the pills. The make axis on the live cars leaf
|
|
189
|
+
* `maxSelected: 1` over a 418-value vocabulary, so "pick one" won and the
|
|
190
|
+
* control it produced was four hundred pills in a 280px rail — a wall
|
|
191
|
+
* with a different border radius. Above the fold the shape a person needs
|
|
192
|
+
* is a search box, whether or not they may tick two; below it,
|
|
193
|
+
* single-choice still means pills, because `isDictionaryFacet` requires
|
|
194
|
+
* more than {@link FACET_DICTIONARY_THRESHOLD} counted buckets;
|
|
195
|
+
* - and a dictionary is a dictionary before it is a checkbox list, because
|
|
196
|
+
* the checkbox list is the shape it was drawn as when nobody could pick a
|
|
197
|
+
* make.
|
|
133
198
|
*/
|
|
134
199
|
export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
135
200
|
const feature = group.feature;
|
|
136
201
|
if (feature !== undefined && featureType(feature) === "hierarchical_select") {
|
|
137
202
|
return "nested";
|
|
138
203
|
}
|
|
204
|
+
if (isDictionaryFacet(group)) return "dictionary";
|
|
139
205
|
return singleChoice(feature) ? "segmented" : "checkbox";
|
|
140
206
|
}
|
|
141
207
|
|
|
@@ -327,6 +393,241 @@ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
|
|
|
327
393
|
);
|
|
328
394
|
}
|
|
329
395
|
|
|
396
|
+
/**
|
|
397
|
+
* The list a dictionary scrolls in. A vocabulary level is 418 makes: without
|
|
398
|
+
* a ceiling the group alone is longer than the rail, and the box that filters
|
|
399
|
+
* it scrolls off the top of the panel while you type into it.
|
|
400
|
+
*/
|
|
401
|
+
const DICTIONARY_LIST: CSSProperties = {
|
|
402
|
+
maxBlockSize: 320,
|
|
403
|
+
overflowY: "auto",
|
|
404
|
+
// The scroll must not clip a focus ring against the panel's edge.
|
|
405
|
+
paddingInlineEnd: spacing[1],
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A dictionary group: what is CHOSEN, a box, and the busiest values.
|
|
410
|
+
*
|
|
411
|
+
* ── Why the chosen values are their own block ─────────────────────────────
|
|
412
|
+
*
|
|
413
|
+
* They are the only rows that are not free to leave. A person who filtered by
|
|
414
|
+
* `Toyota` and then typed `bmw` must still be able to see — and undo — the
|
|
415
|
+
* filter that is narrowing what they are reading; a checkbox that scrolls out
|
|
416
|
+
* of the fold takes its own off-switch with it. So the chosen rows sit above
|
|
417
|
+
* the box, out of the filtered list entirely, and the list below never
|
|
418
|
+
* repeats them.
|
|
419
|
+
*
|
|
420
|
+
* ── The box filters LOCALLY, and matches across alphabets ─────────────────
|
|
421
|
+
*
|
|
422
|
+
* The buckets are already on the client — they arrived with the answer — so
|
|
423
|
+
* the box is a filter over an array and not a request per keystroke.
|
|
424
|
+
* `translitPrefixMatch` is what makes it usable in a catalogue whose values
|
|
425
|
+
* are Latin and whose buyers type Cyrillic: a Cyrillic "timberlend" finds
|
|
426
|
+
* `Timberland`.
|
|
427
|
+
* It is presentation, exactly like the panel's own search: nothing here
|
|
428
|
+
* touches the URL, because what a person typed to FIND a filter is not part
|
|
429
|
+
* of the search they would share.
|
|
430
|
+
*/
|
|
431
|
+
function DictionaryBody(props: {
|
|
432
|
+
readonly group: FacetGroup;
|
|
433
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
434
|
+
/** How many values before the box has to be used. */
|
|
435
|
+
readonly visible: number;
|
|
436
|
+
}): ReactElement {
|
|
437
|
+
const t = useT();
|
|
438
|
+
const { group } = props;
|
|
439
|
+
const [needle, setNeedle] = useState("");
|
|
440
|
+
const [expanded, setExpanded] = useState(false);
|
|
441
|
+
|
|
442
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
443
|
+
const rest = [...group.options.filter((option) => !option.selected)].sort(
|
|
444
|
+
(a, b) => (b.count ?? 0) - (a.count ?? 0)
|
|
445
|
+
);
|
|
446
|
+
const query = needle.trim();
|
|
447
|
+
const matched =
|
|
448
|
+
query === ""
|
|
449
|
+
? rest
|
|
450
|
+
: rest.filter(
|
|
451
|
+
(option) =>
|
|
452
|
+
translitPrefixMatch(query, option.label) ||
|
|
453
|
+
translitPrefixMatch(query, option.value)
|
|
454
|
+
);
|
|
455
|
+
// The fold only exists while nothing is typed: a query has already narrowed
|
|
456
|
+
// the list, and hiding its tail behind "Show all" would hide the answer.
|
|
457
|
+
const folded = query === "" && !expanded && matched.length > props.visible;
|
|
458
|
+
const shown = folded ? matched.slice(0, props.visible) : matched;
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<Flex vertical gap={spacing[1]} data-testid={`facet-dictionary-${group.slug}`}>
|
|
462
|
+
{chosen.length > 0 && (
|
|
463
|
+
<Flex
|
|
464
|
+
vertical
|
|
465
|
+
gap={spacing[1]}
|
|
466
|
+
data-testid={`facet-dictionary-chosen-${group.slug}`}
|
|
467
|
+
>
|
|
468
|
+
<Typography.Text type="secondary">
|
|
469
|
+
{t(SEARCH_I18N_KEYS.facetsDictionaryChosen)}
|
|
470
|
+
</Typography.Text>
|
|
471
|
+
{chosen.map((option) => (
|
|
472
|
+
<CheckboxRow
|
|
473
|
+
key={option.value}
|
|
474
|
+
group={group}
|
|
475
|
+
node={{ option, depth: 0 }}
|
|
476
|
+
onToggle={props.onToggle}
|
|
477
|
+
/>
|
|
478
|
+
))}
|
|
479
|
+
</Flex>
|
|
480
|
+
)}
|
|
481
|
+
<Input
|
|
482
|
+
allowClear
|
|
483
|
+
size="small"
|
|
484
|
+
value={needle}
|
|
485
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
486
|
+
aria-label={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
487
|
+
data-testid={`facet-dictionary-search-${group.slug}`}
|
|
488
|
+
onChange={(event) => {
|
|
489
|
+
setNeedle(event.target.value);
|
|
490
|
+
}}
|
|
491
|
+
/>
|
|
492
|
+
{shown.length === 0 ? (
|
|
493
|
+
<Typography.Text
|
|
494
|
+
type="secondary"
|
|
495
|
+
data-testid={`facet-dictionary-empty-${group.slug}`}
|
|
496
|
+
>
|
|
497
|
+
{t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
|
|
498
|
+
</Typography.Text>
|
|
499
|
+
) : (
|
|
500
|
+
<Flex vertical gap={spacing[1]} style={DICTIONARY_LIST}>
|
|
501
|
+
{shown.map((option) => (
|
|
502
|
+
<CheckboxRow
|
|
503
|
+
key={option.value}
|
|
504
|
+
group={group}
|
|
505
|
+
node={{ option, depth: 0 }}
|
|
506
|
+
onToggle={props.onToggle}
|
|
507
|
+
/>
|
|
508
|
+
))}
|
|
509
|
+
</Flex>
|
|
510
|
+
)}
|
|
511
|
+
{query === "" && matched.length > props.visible && (
|
|
512
|
+
<Button
|
|
513
|
+
type="link"
|
|
514
|
+
size="small"
|
|
515
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
516
|
+
data-testid={`facet-more-${group.slug}`}
|
|
517
|
+
data-analytics="none"
|
|
518
|
+
data-analytics-reason="expanding a filter group is a read, not a flow step"
|
|
519
|
+
onClick={() => {
|
|
520
|
+
setExpanded((was) => !was);
|
|
521
|
+
}}
|
|
522
|
+
>
|
|
523
|
+
{expanded
|
|
524
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
525
|
+
: t(SEARCH_I18N_KEYS.facetsShowAll, { count: matched.length })}
|
|
526
|
+
</Button>
|
|
527
|
+
)}
|
|
528
|
+
</Flex>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The closed face of a dictionary group on DESKTOP: a select-shaped field
|
|
534
|
+
* that reads what is chosen, or "Any".
|
|
535
|
+
*
|
|
536
|
+
* A 418-value vocabulary rendered as a permanently-open box plus a scrolling
|
|
537
|
+
* list is right in a phone sheet, where the sheet IS the disclosure and there
|
|
538
|
+
* is one group on screen. In a 280px rail it is the whole rail: the reference
|
|
539
|
+
* classified draws the make as a field reading "Any" that opens the
|
|
540
|
+
* searchable list, and every axis under it stays reachable at a glance.
|
|
541
|
+
*
|
|
542
|
+
* A native `<button role="combobox">` rather than antd's `Select`, for the
|
|
543
|
+
* same reason the option pills are native buttons: the list underneath is
|
|
544
|
+
* this component's — it carries per-option counts, a chosen block and a fold
|
|
545
|
+
* — and a `Select` that only lends its trigger is a dependency on a popup
|
|
546
|
+
* layer for a border. `aria-expanded` on a real button is the disclosure
|
|
547
|
+
* pattern; Escape closes, and the field keeps focus so the next Tab goes
|
|
548
|
+
* where the person expects.
|
|
549
|
+
*/
|
|
550
|
+
const DICTIONARY_FIELD: CSSProperties = {
|
|
551
|
+
display: "flex",
|
|
552
|
+
alignItems: "center",
|
|
553
|
+
gap: spacing[1],
|
|
554
|
+
inlineSize: "100%",
|
|
555
|
+
minBlockSize: controls.height,
|
|
556
|
+
paddingInline: spacing[2],
|
|
557
|
+
paddingBlock: spacing[1],
|
|
558
|
+
border: `1px solid ${cssVar("border")}`,
|
|
559
|
+
borderRadius: cssVar("radius-md"),
|
|
560
|
+
background: cssVar("surface"),
|
|
561
|
+
color: "inherit",
|
|
562
|
+
font: "inherit",
|
|
563
|
+
textAlign: "start",
|
|
564
|
+
cursor: "pointer",
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/** The chosen values, or the word for "no constraint on this axis". Never a
|
|
568
|
+
* count: "3 chosen" makes a person open the field to find out which three. */
|
|
569
|
+
const DICTIONARY_FIELD_TEXT: CSSProperties = {
|
|
570
|
+
flex: "1 1 auto",
|
|
571
|
+
minInlineSize: 0,
|
|
572
|
+
overflow: "hidden",
|
|
573
|
+
textOverflow: "ellipsis",
|
|
574
|
+
whiteSpace: "nowrap",
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
function DictionaryField(props: {
|
|
578
|
+
readonly group: FacetGroup;
|
|
579
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
580
|
+
readonly visible: number;
|
|
581
|
+
}): ReactElement {
|
|
582
|
+
const t = useT();
|
|
583
|
+
const { group } = props;
|
|
584
|
+
const [open, setOpen] = useState(false);
|
|
585
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
586
|
+
const text =
|
|
587
|
+
chosen.length > 0
|
|
588
|
+
? chosen.map((option) => option.label).join(", ")
|
|
589
|
+
: t(SEARCH_I18N_KEYS.facetsDictionaryAny);
|
|
590
|
+
return (
|
|
591
|
+
<Flex vertical gap={spacing[1]}>
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
role="combobox"
|
|
595
|
+
aria-expanded={open}
|
|
596
|
+
aria-haspopup="listbox"
|
|
597
|
+
aria-label={group.label}
|
|
598
|
+
style={DICTIONARY_FIELD}
|
|
599
|
+
data-testid={`facet-dictionary-field-${group.slug}`}
|
|
600
|
+
data-chosen={chosen.length}
|
|
601
|
+
data-analytics="none"
|
|
602
|
+
data-analytics-reason="opening a filter group is a read, not a flow step"
|
|
603
|
+
onKeyDown={(event) => {
|
|
604
|
+
if (event.key === "Escape" && open) {
|
|
605
|
+
event.preventDefault();
|
|
606
|
+
setOpen(false);
|
|
607
|
+
}
|
|
608
|
+
if (event.key === "ArrowDown" && !open) {
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
setOpen(true);
|
|
611
|
+
}
|
|
612
|
+
}}
|
|
613
|
+
onClick={() => {
|
|
614
|
+
setOpen((was) => !was);
|
|
615
|
+
}}
|
|
616
|
+
>
|
|
617
|
+
<span style={DICTIONARY_FIELD_TEXT}>{text}</span>
|
|
618
|
+
<ChevronGlyph open={open} />
|
|
619
|
+
</button>
|
|
620
|
+
{open && (
|
|
621
|
+
<DictionaryBody
|
|
622
|
+
group={group}
|
|
623
|
+
onToggle={props.onToggle}
|
|
624
|
+
visible={props.visible}
|
|
625
|
+
/>
|
|
626
|
+
)}
|
|
627
|
+
</Flex>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
330
631
|
export interface FacetGroupControlProps {
|
|
331
632
|
readonly group: FacetGroup;
|
|
332
633
|
readonly onToggle: (slug: string, value: string) => void;
|
|
@@ -346,6 +647,14 @@ export interface FacetGroupControlProps {
|
|
|
346
647
|
/** Whether a `collapsible` group STARTS open. Default `true`. The initial
|
|
347
648
|
* value only — the person owns the state after the first click. */
|
|
348
649
|
readonly defaultOpen?: boolean;
|
|
650
|
+
/**
|
|
651
|
+
* How a `"dictionary"` group is drawn. `"field"` is the desktop shape — a
|
|
652
|
+
* select-style field reading its chosen values or "Any", which opens the
|
|
653
|
+
* searchable list; `"inline"` (the default) keeps the list open, the shape
|
|
654
|
+
* a phone sheet wants because the sheet is already the disclosure.
|
|
655
|
+
* Meaningless for the other three shapes.
|
|
656
|
+
*/
|
|
657
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
349
658
|
}
|
|
350
659
|
|
|
351
660
|
export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
@@ -386,6 +695,11 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
386
695
|
data-testid={`facet-group-${group.slug}`}
|
|
387
696
|
data-counted={group.counted ? "true" : "false"}
|
|
388
697
|
data-shape={shape}
|
|
698
|
+
// Who named this heading — `none` means the raw slug is on screen
|
|
699
|
+
// because the answer sent no label and the schema defines none. It is
|
|
700
|
+
// drawn (a heading a person cannot read still beats none) and it is
|
|
701
|
+
// MARKED, so a storefront's own test can refuse to ship it.
|
|
702
|
+
data-label-source={group.labelSource}
|
|
389
703
|
>
|
|
390
704
|
{props.heading !== false &&
|
|
391
705
|
(disclosure ? (
|
|
@@ -421,7 +735,23 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
421
735
|
{/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
|
|
422
736
|
checkboxes are still a hundred stops for a screen reader, and the
|
|
423
737
|
measured rail held 118 of them. */}
|
|
424
|
-
{open &&
|
|
738
|
+
{open &&
|
|
739
|
+
shape === "dictionary" &&
|
|
740
|
+
(props.dictionaryMode === "field" ? (
|
|
741
|
+
<DictionaryField
|
|
742
|
+
group={group}
|
|
743
|
+
onToggle={props.onToggle}
|
|
744
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
745
|
+
/>
|
|
746
|
+
) : (
|
|
747
|
+
<DictionaryBody
|
|
748
|
+
group={group}
|
|
749
|
+
onToggle={props.onToggle}
|
|
750
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
751
|
+
/>
|
|
752
|
+
))}
|
|
753
|
+
|
|
754
|
+
{open && shape !== "dictionary" && (
|
|
425
755
|
<>
|
|
426
756
|
{shape === "segmented" ? (
|
|
427
757
|
<Flex wrap gap={spacing[2]}>
|
|
@@ -95,7 +95,11 @@ import type { FacetPanelBag } from "../headless/FacetPanel.js";
|
|
|
95
95
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
96
96
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
97
97
|
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
facetCoverage,
|
|
100
|
+
facetGroupIsDrawable,
|
|
101
|
+
orderFacetGroupsBySchema,
|
|
102
|
+
} from "../state/facets.js";
|
|
99
103
|
import type { FacetGroup } from "../state/facets.js";
|
|
100
104
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
101
105
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
@@ -176,6 +180,17 @@ export const FACET_OPEN_GROUPS = 5;
|
|
|
176
180
|
*/
|
|
177
181
|
export const FACET_SEARCH_THRESHOLD = 6;
|
|
178
182
|
|
|
183
|
+
/**
|
|
184
|
+
* How many groups the rail draws before the rest go behind one control.
|
|
185
|
+
*
|
|
186
|
+
* The reference classified shows a make, a price, a year and a handful of
|
|
187
|
+
* body axes, and then the word "all filters" — eight is the count that fills
|
|
188
|
+
* a 900px rail once the partition row and the price have taken their share,
|
|
189
|
+
* and it is the point past which a person is scanning rather than reading.
|
|
190
|
+
* The tail is not hidden: `facetsAllFilters` names how many are in it.
|
|
191
|
+
*/
|
|
192
|
+
export const FACET_VISIBLE_GROUPS = 8;
|
|
193
|
+
|
|
179
194
|
/** What a host's category control is handed. */
|
|
180
195
|
export interface CategoryFilterSlotProps {
|
|
181
196
|
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
@@ -256,6 +271,37 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
256
271
|
* count-bearing bar above that one would be the same sentence twice.
|
|
257
272
|
*/
|
|
258
273
|
readonly footerBar?: boolean;
|
|
274
|
+
/**
|
|
275
|
+
* The partition control, drawn at the TOP of the panel — above the price,
|
|
276
|
+
* above every facet.
|
|
277
|
+
*
|
|
278
|
+
* A partition (`children_as: "chips"`) is not a filter among filters: it is
|
|
279
|
+
* which of one template's halves the page is about, and the reference
|
|
280
|
+
* classified puts it first for that reason (a car-type row: all, used,
|
|
281
|
+
* new). It is a slot rather than a component because the
|
|
282
|
+
* children come from the catalogue tree, which is `categories-react`'s;
|
|
283
|
+
* `<PartitionChips variant="segmented">` is what a host usually puts here.
|
|
284
|
+
*/
|
|
285
|
+
readonly partition?: ReactNode;
|
|
286
|
+
/**
|
|
287
|
+
* Slugs pinned above every other group, in the order given — the axis a
|
|
288
|
+
* page has already decided is its subject. See
|
|
289
|
+
* {@link orderFacetGroupsBySchema}.
|
|
290
|
+
*/
|
|
291
|
+
readonly pinnedFacets?: readonly string[];
|
|
292
|
+
/**
|
|
293
|
+
* How many groups before the tail folds under "All filters (K)". Default
|
|
294
|
+
* {@link FACET_VISIBLE_GROUPS}; `null` draws every group, which is what a
|
|
295
|
+
* phone sheet devoted to filtering wants.
|
|
296
|
+
*/
|
|
297
|
+
readonly visibleGroups?: number | null;
|
|
298
|
+
/**
|
|
299
|
+
* How a DICTIONARY group is drawn. `"field"` is the desktop shape — a
|
|
300
|
+
* select-style field reading its chosen values or "Any", which opens the
|
|
301
|
+
* searchable list; `"inline"` (the default) keeps the list open, which is
|
|
302
|
+
* the phone sheet's shape because a sheet is already a disclosure.
|
|
303
|
+
*/
|
|
304
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
259
305
|
}
|
|
260
306
|
|
|
261
307
|
/**
|
|
@@ -463,6 +509,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
463
509
|
// panel is drawn, never what the search is, so it must not survive into a
|
|
464
510
|
// shared link the way everything in `useSearchState` does.
|
|
465
511
|
const [filterQuery, setFilterQuery] = useState("");
|
|
512
|
+
// Whether the tail past `visibleGroups` is open. Presentation, like the
|
|
513
|
+
// panel's own search box: the URL is the search, and how much of the rail a
|
|
514
|
+
// person has unfolded is not part of it.
|
|
515
|
+
const [tailOpen, setTailOpen] = useState(false);
|
|
466
516
|
|
|
467
517
|
return (
|
|
468
518
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -540,6 +590,12 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
540
590
|
)}
|
|
541
591
|
</Flex>
|
|
542
592
|
|
|
593
|
+
{/* The partition first: which half of one template this page is
|
|
594
|
+
about is not a filter among filters. */}
|
|
595
|
+
{props.partition !== undefined && (
|
|
596
|
+
<div data-testid="search-partition">{props.partition}</div>
|
|
597
|
+
)}
|
|
598
|
+
|
|
543
599
|
<CategoryFilter
|
|
544
600
|
{...(props.renderCategoryFilter !== undefined
|
|
545
601
|
? { render: props.renderCategoryFilter }
|
|
@@ -617,25 +673,34 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
617
673
|
// case: a `ref_select` whose config is a bare pointer into
|
|
618
674
|
// a vocabulary this pair cannot read. A heading with no
|
|
619
675
|
// control under it names nothing, so it is not drawn.
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
676
|
+
// SCHEMA order, required first — see
|
|
677
|
+
// `orderFacetGroupsBySchema`. The rail ranked by evidence for
|
|
678
|
+
// two releases, which on a three-listing cars leaf put
|
|
679
|
+
// condition and colour above make, model and year:
|
|
680
|
+
// the busiest axis is the right question for a chip row with
|
|
681
|
+
// room for four and the wrong one for the column a person
|
|
682
|
+
// narrows a catalogue in. Groups the schema does not name
|
|
683
|
+
// keep evidence order among themselves.
|
|
684
|
+
const drawable = orderFacetGroupsBySchema({
|
|
685
|
+
groups: groups.filter(facetGroupIsDrawable),
|
|
686
|
+
...(props.categoryFeatures !== undefined
|
|
687
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
688
|
+
: {}),
|
|
689
|
+
...(props.pinnedFacets !== undefined
|
|
690
|
+
? { pinned: props.pinnedFacets }
|
|
691
|
+
: {}),
|
|
692
|
+
});
|
|
627
693
|
// Which groups OPEN — see the module note. Chosen groups are
|
|
628
|
-
// open unconditionally below;
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
694
|
+
// open unconditionally below; the rest are the first
|
|
695
|
+
// FACET_OPEN_GROUPS of the order above, so the panel's first
|
|
696
|
+
// screen is the axes the category itself calls required. A
|
|
697
|
+
// group the server never counted starts as a header, which is
|
|
698
|
+
// what keeps the wall of "not counted" rows folded.
|
|
699
|
+
const openByOrder = new Set(
|
|
633
700
|
drawable
|
|
634
|
-
.filter((group) => group.counted)
|
|
635
|
-
.map((group) => [group, facetCoverage(group)] as const)
|
|
636
|
-
.sort((a, b) => b[1] - a[1])
|
|
701
|
+
.filter((group) => group.counted || facetCoverage(group) > 0)
|
|
637
702
|
.slice(0, FACET_OPEN_GROUPS)
|
|
638
|
-
.map((
|
|
703
|
+
.map((group) => group.slug)
|
|
639
704
|
);
|
|
640
705
|
const searchable = drawable.length >= FACET_SEARCH_THRESHOLD;
|
|
641
706
|
const needle = searchable
|
|
@@ -648,6 +713,33 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
648
713
|
);
|
|
649
714
|
const listed =
|
|
650
715
|
needle === "" ? drawable : drawable.filter(matches);
|
|
716
|
+
// The tail. Only while nothing is typed: a query has already
|
|
717
|
+
// narrowed the list, and folding its answer would hide the
|
|
718
|
+
// thing that was looked for. One group over the limit is not
|
|
719
|
+
// folded — a control that reveals exactly one heading costs
|
|
720
|
+
// more than it saves.
|
|
721
|
+
const groupLimit =
|
|
722
|
+
props.visibleGroups === null
|
|
723
|
+
? null
|
|
724
|
+
: (props.visibleGroups ?? FACET_VISIBLE_GROUPS);
|
|
725
|
+
const tailFolded =
|
|
726
|
+
groupLimit !== null &&
|
|
727
|
+
needle === "" &&
|
|
728
|
+
listed.length > groupLimit + 1;
|
|
729
|
+
// A CONSTRAINT NEVER FOLDS. The fold hides axes a person has
|
|
730
|
+
// not touched; a group they have chosen a value in stays in
|
|
731
|
+
// the visible band wherever the schema put it, because the
|
|
732
|
+
// control that removes a filter is the one they came back
|
|
733
|
+
// for. (The rail used to rank answered axes to the top for
|
|
734
|
+
// this; schema order is stable under a click, which a rail
|
|
735
|
+
// that reshuffles as you tick is not.)
|
|
736
|
+
const shownGroups =
|
|
737
|
+
tailFolded && !tailOpen && groupLimit !== null
|
|
738
|
+
? listed.filter(
|
|
739
|
+
(group, index) =>
|
|
740
|
+
index < groupLimit || group.selected.length > 0
|
|
741
|
+
)
|
|
742
|
+
: listed;
|
|
651
743
|
return (
|
|
652
744
|
<Flex vertical gap={spacing[4]}>
|
|
653
745
|
{searchable && (
|
|
@@ -684,7 +776,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
684
776
|
a closed header is not an answer. Remounting is the
|
|
685
777
|
honest way to re-ask the question; the person's own
|
|
686
778
|
opens and closes come back when the box clears. */}
|
|
687
|
-
{
|
|
779
|
+
{shownGroups.map((group) => (
|
|
688
780
|
<FacetGroupControl
|
|
689
781
|
key={needle === "" ? group.slug : `${group.slug}:match`}
|
|
690
782
|
group={group}
|
|
@@ -693,10 +785,30 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
693
785
|
defaultOpen={
|
|
694
786
|
needle !== "" ||
|
|
695
787
|
group.selected.length > 0 ||
|
|
696
|
-
|
|
788
|
+
openByOrder.has(group.slug)
|
|
697
789
|
}
|
|
790
|
+
{...(props.dictionaryMode !== undefined
|
|
791
|
+
? { dictionaryMode: props.dictionaryMode }
|
|
792
|
+
: {})}
|
|
698
793
|
/>
|
|
699
794
|
))}
|
|
795
|
+
{tailFolded && (
|
|
796
|
+
<Button
|
|
797
|
+
style={{ alignSelf: "flex-start" }}
|
|
798
|
+
data-testid="facets-all-filters"
|
|
799
|
+
data-analytics="none"
|
|
800
|
+
data-analytics-reason="opening the filter tail is a read, not a flow step"
|
|
801
|
+
onClick={() => {
|
|
802
|
+
setTailOpen((was) => !was);
|
|
803
|
+
}}
|
|
804
|
+
>
|
|
805
|
+
{tailOpen
|
|
806
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
807
|
+
: t(SEARCH_I18N_KEYS.facetsAllFilters, {
|
|
808
|
+
count: listed.length - (groupLimit ?? 0),
|
|
809
|
+
})}
|
|
810
|
+
</Button>
|
|
811
|
+
)}
|
|
700
812
|
<Typography.Text type="secondary">
|
|
701
813
|
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
702
814
|
</Typography.Text>
|
|
@@ -133,7 +133,10 @@ 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 {
|
|
136
|
+
import {
|
|
137
|
+
compareFacetsByEvidence,
|
|
138
|
+
facetGroupIsDrawable,
|
|
139
|
+
} from "../state/facets.js";
|
|
137
140
|
import type { FacetGroup } from "../state/facets.js";
|
|
138
141
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
139
142
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
@@ -400,7 +403,7 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
400
403
|
});
|
|
401
404
|
const groups =
|
|
402
405
|
bag.state.status === "ready"
|
|
403
|
-
? bag.state.data.filter(
|
|
406
|
+
? bag.state.data.filter(facetGroupIsDrawable)
|
|
404
407
|
: [];
|
|
405
408
|
|
|
406
409
|
const close = (): void => {
|