@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +65 -2
  3. package/dist/api/types.d.ts +24 -3
  4. package/dist/api/types.d.ts.map +1 -1
  5. package/dist/api/types.js.map +1 -1
  6. package/dist/default/FacetGroupControl.d.ts +34 -4
  7. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  8. package/dist/default/FacetGroupControl.js +112 -7
  9. package/dist/default/FacetGroupControl.js.map +1 -1
  10. package/dist/default/PartitionChips.d.ts +23 -0
  11. package/dist/default/PartitionChips.d.ts.map +1 -0
  12. package/dist/default/PartitionChips.js +94 -0
  13. package/dist/default/PartitionChips.js.map +1 -0
  14. package/dist/default/PopularValues.d.ts +58 -0
  15. package/dist/default/PopularValues.d.ts.map +1 -0
  16. package/dist/default/PopularValues.js +46 -0
  17. package/dist/default/PopularValues.js.map +1 -0
  18. package/dist/default/index.d.ts +5 -1
  19. package/dist/default/index.d.ts.map +1 -1
  20. package/dist/default/index.js +6 -1
  21. package/dist/default/index.js.map +1 -1
  22. package/dist/headless/useFacetLabels.js +7 -7
  23. package/dist/headless/useFacetLabels.js.map +1 -1
  24. package/dist/i18n/es.d.ts.map +1 -1
  25. package/dist/i18n/es.js +6 -0
  26. package/dist/i18n/es.js.map +1 -1
  27. package/dist/i18n/keys.d.ts +30 -0
  28. package/dist/i18n/keys.d.ts.map +1 -1
  29. package/dist/i18n/keys.js +37 -0
  30. package/dist/i18n/keys.js.map +1 -1
  31. package/dist/i18n/ru.d.ts.map +1 -1
  32. package/dist/i18n/ru.js +6 -0
  33. package/dist/i18n/ru.js.map +1 -1
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/state/facets.d.ts +26 -6
  39. package/dist/state/facets.d.ts.map +1 -1
  40. package/dist/state/facets.js +72 -15
  41. package/dist/state/facets.js.map +1 -1
  42. package/dist/state/translit.d.ts +58 -0
  43. package/dist/state/translit.d.ts.map +1 -0
  44. package/dist/state/translit.js +115 -0
  45. package/dist/state/translit.js.map +1 -0
  46. package/llms.txt +3 -1
  47. package/manifest.json +38 -1
  48. package/nav-manifest.json +1 -1
  49. package/package.json +7 -7
  50. package/src/analytics/generated/events.json +1 -1
  51. package/src/api/types.ts +29 -3
  52. package/src/default/FacetGroupControl.tsx +213 -8
  53. package/src/default/PartitionChips.tsx +161 -0
  54. package/src/default/PopularValues.tsx +151 -0
  55. package/src/default/index.ts +15 -0
  56. package/src/headless/useFacetLabels.ts +7 -7
  57. package/src/i18n/es.ts +6 -0
  58. package/src/i18n/keys.ts +38 -0
  59. package/src/i18n/ru.ts +6 -0
  60. package/src/index.ts +9 -0
  61. package/src/state/facets.ts +107 -27
  62. 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 { featureConfig, featureType } from "@stapel/attributes-react";
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
- /** The three shapes a facet group takes. */
81
- export type FacetGroupShape = "segmented" | "nested" | "checkbox";
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
- * Which of the three shapes a group takes.
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
- return singleChoice(feature) ? "segmented" : "checkbox";
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
+ }
@@ -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 leaves an option it could not name with `label === value` — which is
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.label === option.value)
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 whose
161
- // label already differs from its value was named by the server or
162
- // the schema, and the host does not get to overwrite either.
163
- if (option.label !== option.value) return 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":