@stapel/search-react 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +65 -2
- package/dist/api/types.d.ts +24 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +34 -4
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +112 -7
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +23 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +94 -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/index.d.ts +5 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- 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 +6 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +30 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +37 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +6 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +26 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +72 -15
- package/dist/state/facets.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 +38 -1
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +29 -3
- package/src/default/FacetGroupControl.tsx +213 -8
- package/src/default/PartitionChips.tsx +161 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/index.ts +15 -0
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +6 -0
- package/src/i18n/keys.ts +38 -0
- package/src/i18n/ru.ts +6 -0
- package/src/index.ts +9 -0
- package/src/state/facets.ts +107 -27
- 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
69
|
import { radii, spacing } from "@stapel/tokens";
|
|
65
|
-
import {
|
|
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,17 +147,52 @@ 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.
|
|
130
183
|
*
|
|
131
184
|
* Order matters: a hierarchical facet is nested even when it is single-choice,
|
|
132
|
-
* because losing the tree costs more than losing the pills
|
|
185
|
+
* because losing the tree costs more than losing the pills; and a dictionary
|
|
186
|
+
* is a dictionary before it is a checkbox list, because the checkbox list is
|
|
187
|
+
* the shape it was drawn as when nobody could pick a make.
|
|
133
188
|
*/
|
|
134
189
|
export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
135
190
|
const feature = group.feature;
|
|
136
191
|
if (feature !== undefined && featureType(feature) === "hierarchical_select") {
|
|
137
192
|
return "nested";
|
|
138
193
|
}
|
|
139
|
-
|
|
194
|
+
if (singleChoice(feature)) return "segmented";
|
|
195
|
+
return isDictionaryFacet(group) ? "dictionary" : "checkbox";
|
|
140
196
|
}
|
|
141
197
|
|
|
142
198
|
/**
|
|
@@ -327,6 +383,142 @@ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
|
|
|
327
383
|
);
|
|
328
384
|
}
|
|
329
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The list a dictionary scrolls in. A vocabulary level is 418 makes: without
|
|
388
|
+
* a ceiling the group alone is longer than the rail, and the box that filters
|
|
389
|
+
* it scrolls off the top of the panel while you type into it.
|
|
390
|
+
*/
|
|
391
|
+
const DICTIONARY_LIST: CSSProperties = {
|
|
392
|
+
maxBlockSize: 320,
|
|
393
|
+
overflowY: "auto",
|
|
394
|
+
// The scroll must not clip a focus ring against the panel's edge.
|
|
395
|
+
paddingInlineEnd: spacing[1],
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A dictionary group: what is CHOSEN, a box, and the busiest values.
|
|
400
|
+
*
|
|
401
|
+
* ── Why the chosen values are their own block ─────────────────────────────
|
|
402
|
+
*
|
|
403
|
+
* They are the only rows that are not free to leave. A person who filtered by
|
|
404
|
+
* `Toyota` and then typed `bmw` must still be able to see — and undo — the
|
|
405
|
+
* filter that is narrowing what they are reading; a checkbox that scrolls out
|
|
406
|
+
* of the fold takes its own off-switch with it. So the chosen rows sit above
|
|
407
|
+
* the box, out of the filtered list entirely, and the list below never
|
|
408
|
+
* repeats them.
|
|
409
|
+
*
|
|
410
|
+
* ── The box filters LOCALLY, and matches across alphabets ─────────────────
|
|
411
|
+
*
|
|
412
|
+
* The buckets are already on the client — they arrived with the answer — so
|
|
413
|
+
* the box is a filter over an array and not a request per keystroke.
|
|
414
|
+
* `translitPrefixMatch` is what makes it usable in a catalogue whose values
|
|
415
|
+
* are Latin and whose buyers type Cyrillic: a Cyrillic "timberlend" finds
|
|
416
|
+
* `Timberland`.
|
|
417
|
+
* It is presentation, exactly like the panel's own search: nothing here
|
|
418
|
+
* touches the URL, because what a person typed to FIND a filter is not part
|
|
419
|
+
* of the search they would share.
|
|
420
|
+
*/
|
|
421
|
+
function DictionaryBody(props: {
|
|
422
|
+
readonly group: FacetGroup;
|
|
423
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
424
|
+
/** How many values before the box has to be used. */
|
|
425
|
+
readonly visible: number;
|
|
426
|
+
}): ReactElement {
|
|
427
|
+
const t = useT();
|
|
428
|
+
const { group } = props;
|
|
429
|
+
const [needle, setNeedle] = useState("");
|
|
430
|
+
const [expanded, setExpanded] = useState(false);
|
|
431
|
+
|
|
432
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
433
|
+
const rest = [...group.options.filter((option) => !option.selected)].sort(
|
|
434
|
+
(a, b) => (b.count ?? 0) - (a.count ?? 0)
|
|
435
|
+
);
|
|
436
|
+
const query = needle.trim();
|
|
437
|
+
const matched =
|
|
438
|
+
query === ""
|
|
439
|
+
? rest
|
|
440
|
+
: rest.filter(
|
|
441
|
+
(option) =>
|
|
442
|
+
translitPrefixMatch(query, option.label) ||
|
|
443
|
+
translitPrefixMatch(query, option.value)
|
|
444
|
+
);
|
|
445
|
+
// The fold only exists while nothing is typed: a query has already narrowed
|
|
446
|
+
// the list, and hiding its tail behind "Show all" would hide the answer.
|
|
447
|
+
const folded = query === "" && !expanded && matched.length > props.visible;
|
|
448
|
+
const shown = folded ? matched.slice(0, props.visible) : matched;
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<Flex vertical gap={spacing[1]} data-testid={`facet-dictionary-${group.slug}`}>
|
|
452
|
+
{chosen.length > 0 && (
|
|
453
|
+
<Flex
|
|
454
|
+
vertical
|
|
455
|
+
gap={spacing[1]}
|
|
456
|
+
data-testid={`facet-dictionary-chosen-${group.slug}`}
|
|
457
|
+
>
|
|
458
|
+
<Typography.Text type="secondary">
|
|
459
|
+
{t(SEARCH_I18N_KEYS.facetsDictionaryChosen)}
|
|
460
|
+
</Typography.Text>
|
|
461
|
+
{chosen.map((option) => (
|
|
462
|
+
<CheckboxRow
|
|
463
|
+
key={option.value}
|
|
464
|
+
group={group}
|
|
465
|
+
node={{ option, depth: 0 }}
|
|
466
|
+
onToggle={props.onToggle}
|
|
467
|
+
/>
|
|
468
|
+
))}
|
|
469
|
+
</Flex>
|
|
470
|
+
)}
|
|
471
|
+
<Input
|
|
472
|
+
allowClear
|
|
473
|
+
size="small"
|
|
474
|
+
value={needle}
|
|
475
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
476
|
+
aria-label={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
477
|
+
data-testid={`facet-dictionary-search-${group.slug}`}
|
|
478
|
+
onChange={(event) => {
|
|
479
|
+
setNeedle(event.target.value);
|
|
480
|
+
}}
|
|
481
|
+
/>
|
|
482
|
+
{shown.length === 0 ? (
|
|
483
|
+
<Typography.Text
|
|
484
|
+
type="secondary"
|
|
485
|
+
data-testid={`facet-dictionary-empty-${group.slug}`}
|
|
486
|
+
>
|
|
487
|
+
{t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
|
|
488
|
+
</Typography.Text>
|
|
489
|
+
) : (
|
|
490
|
+
<Flex vertical gap={spacing[1]} style={DICTIONARY_LIST}>
|
|
491
|
+
{shown.map((option) => (
|
|
492
|
+
<CheckboxRow
|
|
493
|
+
key={option.value}
|
|
494
|
+
group={group}
|
|
495
|
+
node={{ option, depth: 0 }}
|
|
496
|
+
onToggle={props.onToggle}
|
|
497
|
+
/>
|
|
498
|
+
))}
|
|
499
|
+
</Flex>
|
|
500
|
+
)}
|
|
501
|
+
{query === "" && matched.length > props.visible && (
|
|
502
|
+
<Button
|
|
503
|
+
type="link"
|
|
504
|
+
size="small"
|
|
505
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
506
|
+
data-testid={`facet-more-${group.slug}`}
|
|
507
|
+
data-analytics="none"
|
|
508
|
+
data-analytics-reason="expanding a filter group is a read, not a flow step"
|
|
509
|
+
onClick={() => {
|
|
510
|
+
setExpanded((was) => !was);
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
{expanded
|
|
514
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
515
|
+
: t(SEARCH_I18N_KEYS.facetsShowAll, { count: matched.length })}
|
|
516
|
+
</Button>
|
|
517
|
+
)}
|
|
518
|
+
</Flex>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
330
522
|
export interface FacetGroupControlProps {
|
|
331
523
|
readonly group: FacetGroup;
|
|
332
524
|
readonly onToggle: (slug: string, value: string) => void;
|
|
@@ -386,6 +578,11 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
386
578
|
data-testid={`facet-group-${group.slug}`}
|
|
387
579
|
data-counted={group.counted ? "true" : "false"}
|
|
388
580
|
data-shape={shape}
|
|
581
|
+
// Who named this heading — `none` means the raw slug is on screen
|
|
582
|
+
// because the answer sent no label and the schema defines none. It is
|
|
583
|
+
// drawn (a heading a person cannot read still beats none) and it is
|
|
584
|
+
// MARKED, so a storefront's own test can refuse to ship it.
|
|
585
|
+
data-label-source={group.labelSource}
|
|
389
586
|
>
|
|
390
587
|
{props.heading !== false &&
|
|
391
588
|
(disclosure ? (
|
|
@@ -421,7 +618,15 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
421
618
|
{/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
|
|
422
619
|
checkboxes are still a hundred stops for a screen reader, and the
|
|
423
620
|
measured rail held 118 of them. */}
|
|
424
|
-
{open && (
|
|
621
|
+
{open && shape === "dictionary" && (
|
|
622
|
+
<DictionaryBody
|
|
623
|
+
group={group}
|
|
624
|
+
onToggle={props.onToggle}
|
|
625
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
626
|
+
/>
|
|
627
|
+
)}
|
|
628
|
+
|
|
629
|
+
{open && shape !== "dictionary" && (
|
|
425
630
|
<>
|
|
426
631
|
{shape === "segmented" ? (
|
|
427
632
|
<Flex wrap gap={spacing[2]}>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<PartitionChips>` — the children of a `chips` category, as one row of
|
|
3
|
+
* single-select chips.
|
|
4
|
+
*
|
|
5
|
+
* A partition is a category whose children are not subcategories but one
|
|
6
|
+
* template split by a value their names express: buy / sell / let / rent,
|
|
7
|
+
* new / used, for boys / for girls. They keep their
|
|
8
|
+
* ids, their paths and their URLs — a listing still lands on a child — and
|
|
9
|
+
* only the PRESENTATION changes: the parent draws a feed, and the children
|
|
10
|
+
* are a choice above it rather than a grid of tiles the visitor has to pass
|
|
11
|
+
* through.
|
|
12
|
+
*
|
|
13
|
+
* Which categories are a partition is not decided here and not decided by
|
|
14
|
+
* this pair: `children_as` is a stored, derivable field on the category, and
|
|
15
|
+
* the storefront hands this component the children it resolved. What this
|
|
16
|
+
* component owns is that the choice is SINGLE-select and that "all" — the
|
|
17
|
+
* parent, unnarrowed — is one of the options rather than a way of clearing
|
|
18
|
+
* the others.
|
|
19
|
+
*
|
|
20
|
+
* ── Why a radiogroup and not a row of toggles ─────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Because exactly one of them is true at a time, and `aria-pressed` buttons
|
|
23
|
+
* say the opposite: they announce a set of independent switches, so a screen
|
|
24
|
+
* reader user hears no reason why pressing one released another. A radiogroup
|
|
25
|
+
* with roving tabindex is the pattern for "one of these": Tab reaches the row
|
|
26
|
+
* once and lands on the chosen chip, the arrow keys move along it, and the
|
|
27
|
+
* group's own name says what is being chosen.
|
|
28
|
+
*/
|
|
29
|
+
import { useRef } from "react";
|
|
30
|
+
import type {
|
|
31
|
+
CSSProperties,
|
|
32
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
33
|
+
ReactElement,
|
|
34
|
+
ReactNode,
|
|
35
|
+
} from "react";
|
|
36
|
+
import { Button } from "antd";
|
|
37
|
+
import { useT } from "@stapel/core";
|
|
38
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
39
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
40
|
+
|
|
41
|
+
/** One child of a partitioned category. `path` is the slash-joined id path
|
|
42
|
+
* the `category` parameter takes — the same string `SearchQueryState.category`
|
|
43
|
+
* carries, so a host never rebuilds it from ids. */
|
|
44
|
+
export interface PartitionChild {
|
|
45
|
+
readonly id: number | string;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PartitionChipsProps {
|
|
51
|
+
/** The children, in the order the catalogue declares them. */
|
|
52
|
+
readonly items: readonly PartitionChild[];
|
|
53
|
+
/** The chosen child's `path`, or `null` for the parent itself. CONTROLLED:
|
|
54
|
+
* this row keeps no state, because the choice is a `category` in the URL. */
|
|
55
|
+
readonly value: string | null;
|
|
56
|
+
readonly onChange: (path: string | null) => void;
|
|
57
|
+
/** The first chip's label. Defaults to `search.partition.all`. */
|
|
58
|
+
readonly allLabel?: ReactNode;
|
|
59
|
+
/** The row's accessible name. Defaults to `search.partition.label`. */
|
|
60
|
+
readonly label?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CHIP: CSSProperties = { borderRadius: radii.full };
|
|
64
|
+
|
|
65
|
+
const ROW: CSSProperties = {
|
|
66
|
+
display: "flex",
|
|
67
|
+
flexWrap: "wrap",
|
|
68
|
+
gap: spacing[2],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The row's cells, as `[value, label]` — the parent first, then the
|
|
72
|
+
* children in catalogue order. */
|
|
73
|
+
function cells(
|
|
74
|
+
items: readonly PartitionChild[],
|
|
75
|
+
allLabel: ReactNode
|
|
76
|
+
): readonly (readonly [string | null, ReactNode])[] {
|
|
77
|
+
return [
|
|
78
|
+
[null, allLabel] as const,
|
|
79
|
+
...items.map((item) => [item.path, item.name] as const),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
84
|
+
const t = useT();
|
|
85
|
+
const row = useRef<HTMLDivElement>(null);
|
|
86
|
+
const options = cells(
|
|
87
|
+
props.items,
|
|
88
|
+
props.allLabel ?? t(SEARCH_I18N_KEYS.partitionAll)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Arrow keys move the choice AND the focus, which is what a radiogroup
|
|
93
|
+
* does: in a single-select row the focused option is the selected one, so
|
|
94
|
+
* moving focus without choosing would leave the two disagreeing.
|
|
95
|
+
*/
|
|
96
|
+
const onKeyDown =
|
|
97
|
+
(index: number) =>
|
|
98
|
+
(event: ReactKeyboardEvent): void => {
|
|
99
|
+
const step =
|
|
100
|
+
event.key === "ArrowRight" || event.key === "ArrowDown"
|
|
101
|
+
? 1
|
|
102
|
+
: event.key === "ArrowLeft" || event.key === "ArrowUp"
|
|
103
|
+
? -1
|
|
104
|
+
: event.key === "Home"
|
|
105
|
+
? -index
|
|
106
|
+
: event.key === "End"
|
|
107
|
+
? options.length - 1 - index
|
|
108
|
+
: 0;
|
|
109
|
+
if (step === 0) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
const next = (index + step + options.length) % options.length;
|
|
112
|
+
const cell = options[next];
|
|
113
|
+
if (cell === undefined) return;
|
|
114
|
+
props.onChange(cell[0]);
|
|
115
|
+
const buttons = row.current?.querySelectorAll<HTMLElement>('[role="radio"]');
|
|
116
|
+
buttons?.[next]?.focus();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// A `value` naming no cell (a link into a child that has since moved) must
|
|
120
|
+
// still leave the row reachable by Tab, so the roving stop falls back to the
|
|
121
|
+
// parent chip rather than vanishing.
|
|
122
|
+
const active = options.findIndex(([value]) => value === props.value);
|
|
123
|
+
const stop = active >= 0 ? active : 0;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
style={ROW}
|
|
128
|
+
ref={row}
|
|
129
|
+
role="radiogroup"
|
|
130
|
+
aria-label={props.label ?? t(SEARCH_I18N_KEYS.partitionLabel)}
|
|
131
|
+
data-testid="partition-chips"
|
|
132
|
+
>
|
|
133
|
+
{options.map(([value, label], index) => {
|
|
134
|
+
const selected = value === props.value;
|
|
135
|
+
return (
|
|
136
|
+
<Button
|
|
137
|
+
key={value ?? "__all__"}
|
|
138
|
+
size="small"
|
|
139
|
+
shape="round"
|
|
140
|
+
type={selected ? "primary" : "default"}
|
|
141
|
+
role="radio"
|
|
142
|
+
aria-checked={selected}
|
|
143
|
+
// Roving tabindex: the row is ONE Tab stop and it lands on the
|
|
144
|
+
// chosen chip, not on the first of eight.
|
|
145
|
+
tabIndex={index === stop ? 0 : -1}
|
|
146
|
+
style={CHIP}
|
|
147
|
+
data-testid={`partition-chip-${value ?? "all"}`}
|
|
148
|
+
data-analytics="none"
|
|
149
|
+
data-analytics-reason="choosing a section is a read, not a flow step"
|
|
150
|
+
onKeyDown={onKeyDown(index)}
|
|
151
|
+
onClick={() => {
|
|
152
|
+
props.onChange(value);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{label}
|
|
156
|
+
</Button>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<PopularValues>` — the busiest values of one facet, printed as words a
|
|
3
|
+
* person can click, above the results.
|
|
4
|
+
*
|
|
5
|
+
* A dictionary facet in the rail answers "narrow this list"; it does not
|
|
6
|
+
* answer "what is IN this category". On a feed page of a cars leaf the second
|
|
7
|
+
* question is the one a visitor arrives with, and its answer is already in the
|
|
8
|
+
* envelope: `Toyota 802` is a bucket and its count. Printed as a multi-column
|
|
9
|
+
* block it is a table of contents for the category — the eleven makes that
|
|
10
|
+
* account for most of it, in one glance, each one a filter.
|
|
11
|
+
*
|
|
12
|
+
* ── What it is NOT ────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Not a replacement for the facet control: it shows the busy head of ONE
|
|
15
|
+
* group and says so with a link into the whole thing (`onShowAll`). Not a
|
|
16
|
+
* second source of counts either — the numbers are the answer's own
|
|
17
|
+
* drill-down counts, the same ones the checkbox rows carry, so a value cannot
|
|
18
|
+
* read `802` here and `93` in the panel.
|
|
19
|
+
*
|
|
20
|
+
* ── Hidden on a phone by a PROP ───────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* `hidden` rather than a media query inside, because whether a 390px screen
|
|
23
|
+
* has room for a block of forty links is a decision about the PAGE, and the
|
|
24
|
+
* page is the storefront's. A component that hid itself below some width of
|
|
25
|
+
* its own choosing would take that decision away from the only surface that
|
|
26
|
+
* knows what else is on screen — and would still render the DOM, which is
|
|
27
|
+
* what `display: none` costs a screen reader.
|
|
28
|
+
*/
|
|
29
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
30
|
+
import { Button, Flex, Typography } from "antd";
|
|
31
|
+
import { useT } from "@stapel/core";
|
|
32
|
+
import { spacing } from "@stapel/tokens";
|
|
33
|
+
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
34
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
35
|
+
|
|
36
|
+
/** How many values the block prints before the link into the full control. */
|
|
37
|
+
export const POPULAR_VALUES_LIMIT = 12;
|
|
38
|
+
|
|
39
|
+
/** How many columns the list flows into. Three fills a desktop content column
|
|
40
|
+
* without turning a make into a two-line wrap. */
|
|
41
|
+
export const POPULAR_VALUES_COLUMNS = 3;
|
|
42
|
+
|
|
43
|
+
/** A value with no evidence behind it is not a popular value. Uncounted
|
|
44
|
+
* options carry `count: null` and are dropped here rather than printed with a
|
|
45
|
+
* blank where the number belongs — the block IS the numbers. */
|
|
46
|
+
function hasEvidence(option: FacetOption): boolean {
|
|
47
|
+
return option.count !== null && option.count > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The busiest values of the group, count-descending, capped. */
|
|
51
|
+
export function popularOptions(
|
|
52
|
+
group: FacetGroup,
|
|
53
|
+
limit: number = POPULAR_VALUES_LIMIT
|
|
54
|
+
): readonly FacetOption[] {
|
|
55
|
+
return [...group.options.filter(hasEvidence)]
|
|
56
|
+
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
|
|
57
|
+
.slice(0, limit);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ROW: CSSProperties = {
|
|
61
|
+
// `break-inside` keeps a value and its count on one line when the browser
|
|
62
|
+
// decides where the column ends.
|
|
63
|
+
breakInside: "avoid",
|
|
64
|
+
display: "flex",
|
|
65
|
+
gap: spacing[2],
|
|
66
|
+
alignItems: "baseline",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface PopularValuesProps {
|
|
70
|
+
/** The group to print — normally the first `ref_select` of the plan. */
|
|
71
|
+
readonly group: FacetGroup;
|
|
72
|
+
/** Apply one value. Same signature as the panel's `toggle`, so a host can
|
|
73
|
+
* hand the facet bag's own function straight in. */
|
|
74
|
+
readonly onApply: (slug: string, value: string) => void;
|
|
75
|
+
/** Draw nothing. The phone, decided by the page — see the module note. */
|
|
76
|
+
readonly hidden?: boolean;
|
|
77
|
+
/** How many values. Default {@link POPULAR_VALUES_LIMIT}. */
|
|
78
|
+
readonly limit?: number;
|
|
79
|
+
/** How many columns. Default {@link POPULAR_VALUES_COLUMNS}. */
|
|
80
|
+
readonly columns?: number;
|
|
81
|
+
/** The block's heading. Defaults to the group's own label; `null` draws
|
|
82
|
+
* none, for a surface that has already named the axis. */
|
|
83
|
+
readonly heading?: ReactNode;
|
|
84
|
+
/** Open the full control. Absent draws no link — a link that goes nowhere
|
|
85
|
+
* is worse than a block that stops. */
|
|
86
|
+
readonly onShowAll?: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
90
|
+
const t = useT();
|
|
91
|
+
const { group } = props;
|
|
92
|
+
if (props.hidden === true) return null;
|
|
93
|
+
const options = popularOptions(group, props.limit ?? POPULAR_VALUES_LIMIT);
|
|
94
|
+
if (options.length === 0) return null;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Flex
|
|
98
|
+
vertical
|
|
99
|
+
gap={spacing[2]}
|
|
100
|
+
data-testid={`popular-values-${group.slug}`}
|
|
101
|
+
data-label-source={group.labelSource}
|
|
102
|
+
>
|
|
103
|
+
{props.heading !== null && (
|
|
104
|
+
<Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
|
|
105
|
+
)}
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
columnCount: props.columns ?? POPULAR_VALUES_COLUMNS,
|
|
109
|
+
columnGap: spacing[4],
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{options.map((option) => (
|
|
113
|
+
<div key={option.value} style={ROW}>
|
|
114
|
+
<Button
|
|
115
|
+
type="link"
|
|
116
|
+
size="small"
|
|
117
|
+
style={{ paddingInline: 0, height: "auto" }}
|
|
118
|
+
data-testid={`popular-value-${group.slug}-${option.value}`}
|
|
119
|
+
data-analytics="none"
|
|
120
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
121
|
+
onClick={() => {
|
|
122
|
+
props.onApply(group.slug, option.value);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{option.label}
|
|
126
|
+
</Button>
|
|
127
|
+
<Typography.Text
|
|
128
|
+
type="secondary"
|
|
129
|
+
data-testid={`popular-count-${group.slug}-${option.value}`}
|
|
130
|
+
>
|
|
131
|
+
{option.count}
|
|
132
|
+
</Typography.Text>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
{props.onShowAll !== undefined && (
|
|
137
|
+
<Button
|
|
138
|
+
type="link"
|
|
139
|
+
size="small"
|
|
140
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
141
|
+
data-testid={`popular-all-${group.slug}`}
|
|
142
|
+
data-analytics="none"
|
|
143
|
+
data-analytics-reason="opening a filter control is a read, not a flow step"
|
|
144
|
+
onClick={props.onShowAll}
|
|
145
|
+
>
|
|
146
|
+
{t(SEARCH_I18N_KEYS.facetsPopularAll)}
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
149
|
+
</Flex>
|
|
150
|
+
);
|
|
151
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -72,6 +72,8 @@ export {
|
|
|
72
72
|
FacetGroupControl,
|
|
73
73
|
facetGroupShape,
|
|
74
74
|
facetOptionNodes,
|
|
75
|
+
isDictionaryFacet,
|
|
76
|
+
FACET_DICTIONARY_THRESHOLD,
|
|
75
77
|
FACET_VISIBLE_OPTIONS,
|
|
76
78
|
} from "./FacetGroupControl.js";
|
|
77
79
|
export type {
|
|
@@ -80,6 +82,19 @@ export type {
|
|
|
80
82
|
FacetOptionNode,
|
|
81
83
|
} from "./FacetGroupControl.js";
|
|
82
84
|
|
|
85
|
+
// ── the browse surfaces a storefront PLACES (this pair does not lay them
|
|
86
|
+
// out: where a popular-values block or a partition row belongs on a
|
|
87
|
+
// category page is the page's decision) ──────────────────────────────────
|
|
88
|
+
export {
|
|
89
|
+
PopularValues,
|
|
90
|
+
popularOptions,
|
|
91
|
+
POPULAR_VALUES_COLUMNS,
|
|
92
|
+
POPULAR_VALUES_LIMIT,
|
|
93
|
+
} from "./PopularValues.js";
|
|
94
|
+
export type { PopularValuesProps } from "./PopularValues.js";
|
|
95
|
+
export { PartitionChips } from "./PartitionChips.js";
|
|
96
|
+
export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
|
|
97
|
+
|
|
83
98
|
export { FacetPanelPane } from "./FacetPanelPane.js";
|
|
84
99
|
export type {
|
|
85
100
|
FacetPanelPaneProps,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* 4. the raw value.
|
|
33
33
|
*
|
|
34
34
|
* `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
|
|
35
|
-
* and it
|
|
35
|
+
* and it marks an option it could not name `labelSource: "none"` — which is
|
|
36
36
|
* what makes "did anyone name this?" answerable without a second lookup. Only
|
|
37
37
|
* those values are asked about, so the host is never called for a value the
|
|
38
38
|
* server or the schema already captioned, and a resolver that returns nothing
|
|
@@ -98,7 +98,7 @@ const NO_REQUESTS: readonly FacetLabelRequest[] = [];
|
|
|
98
98
|
/** The values of one group nobody has named — see the precedence note above. */
|
|
99
99
|
function unresolvedValues(group: FacetGroup): readonly string[] {
|
|
100
100
|
return group.options
|
|
101
|
-
.filter((option) => option.
|
|
101
|
+
.filter((option) => option.labelSource === "none")
|
|
102
102
|
.map((option) => option.value)
|
|
103
103
|
.sort((a, b) => a.localeCompare(b));
|
|
104
104
|
}
|
|
@@ -157,14 +157,14 @@ export function useHostFacetLabels(
|
|
|
157
157
|
return {
|
|
158
158
|
...group,
|
|
159
159
|
options: group.options.map((option) => {
|
|
160
|
-
// Precedence again, enforced rather than assumed: an option
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
if (option.
|
|
160
|
+
// Precedence again, enforced rather than assumed: an option the
|
|
161
|
+
// server or the schema already named is not the host's to
|
|
162
|
+
// overwrite. The source says so; the strings cannot.
|
|
163
|
+
if (option.labelSource !== "none") return option;
|
|
164
164
|
const caption = named[option.value];
|
|
165
165
|
return caption === undefined || caption.length === 0
|
|
166
166
|
? option
|
|
167
|
-
: { ...option, label: caption };
|
|
167
|
+
: { ...option, label: caption, labelSource: "host" as const };
|
|
168
168
|
}),
|
|
169
169
|
};
|
|
170
170
|
})
|
package/src/i18n/es.ts
CHANGED
|
@@ -93,6 +93,12 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
93
93
|
"search.facets.show_less": "Ver menos",
|
|
94
94
|
"search.facets.search": "Buscar un filtro",
|
|
95
95
|
"search.facets.search_empty": "Ningún filtro coincide con esto",
|
|
96
|
+
"search.facets.dictionary_search": "Buscar un valor",
|
|
97
|
+
"search.facets.dictionary_empty": "Ningún valor coincide con esto",
|
|
98
|
+
"search.facets.dictionary_chosen": "Elegido",
|
|
99
|
+
"search.facets.popular_all": "Todos",
|
|
100
|
+
"search.partition.all": "Todos",
|
|
101
|
+
"search.partition.label": "Sección",
|
|
96
102
|
"search.facets.match_count.one": "{count} anuncio coincide",
|
|
97
103
|
"search.facets.match_count.other": "{count} anuncios coinciden",
|
|
98
104
|
"search.facets.range_invalid":
|