@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/MODULE.md +12 -0
  3. package/README.md +143 -2
  4. package/dist/api/types.d.ts +37 -3
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js.map +1 -1
  7. package/dist/default/FacetGroupControl.d.ts +53 -5
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  9. package/dist/default/FacetGroupControl.js +189 -8
  10. package/dist/default/FacetGroupControl.js.map +1 -1
  11. package/dist/default/FacetPanelPane.d.ts +41 -0
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +72 -18
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/FilterChips.d.ts.map +1 -1
  16. package/dist/default/FilterChips.js +2 -2
  17. package/dist/default/FilterChips.js.map +1 -1
  18. package/dist/default/PartitionChips.d.ts +37 -0
  19. package/dist/default/PartitionChips.d.ts.map +1 -0
  20. package/dist/default/PartitionChips.js +120 -0
  21. package/dist/default/PartitionChips.js.map +1 -0
  22. package/dist/default/PopularValues.d.ts +58 -0
  23. package/dist/default/PopularValues.d.ts.map +1 -0
  24. package/dist/default/PopularValues.js +46 -0
  25. package/dist/default/PopularValues.js.map +1 -0
  26. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  27. package/dist/default/RangeFilterRow.js +65 -11
  28. package/dist/default/RangeFilterRow.js.map +1 -1
  29. package/dist/default/SearchPage.d.ts +49 -0
  30. package/dist/default/SearchPage.d.ts.map +1 -1
  31. package/dist/default/SearchPage.js +43 -2
  32. package/dist/default/SearchPage.js.map +1 -1
  33. package/dist/default/index.d.ts +7 -3
  34. package/dist/default/index.d.ts.map +1 -1
  35. package/dist/default/index.js +8 -3
  36. package/dist/default/index.js.map +1 -1
  37. package/dist/headless/useFacetLabels.js +7 -7
  38. package/dist/headless/useFacetLabels.js.map +1 -1
  39. package/dist/i18n/es.d.ts.map +1 -1
  40. package/dist/i18n/es.js +9 -0
  41. package/dist/i18n/es.js.map +1 -1
  42. package/dist/i18n/keys.d.ts +52 -0
  43. package/dist/i18n/keys.d.ts.map +1 -1
  44. package/dist/i18n/keys.js +62 -0
  45. package/dist/i18n/keys.js.map +1 -1
  46. package/dist/i18n/ru.d.ts.map +1 -1
  47. package/dist/i18n/ru.js +9 -0
  48. package/dist/i18n/ru.js.map +1 -1
  49. package/dist/index.d.ts +5 -4
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +3 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/state/facets.d.ts +90 -6
  54. package/dist/state/facets.d.ts.map +1 -1
  55. package/dist/state/facets.js +216 -15
  56. package/dist/state/facets.js.map +1 -1
  57. package/dist/state/ranges.d.ts +22 -0
  58. package/dist/state/ranges.d.ts.map +1 -1
  59. package/dist/state/ranges.js +30 -0
  60. package/dist/state/ranges.js.map +1 -1
  61. package/dist/state/translit.d.ts +58 -0
  62. package/dist/state/translit.d.ts.map +1 -0
  63. package/dist/state/translit.js +115 -0
  64. package/dist/state/translit.js.map +1 -0
  65. package/llms.txt +3 -1
  66. package/manifest.json +45 -1
  67. package/nav-manifest.json +1 -1
  68. package/package.json +8 -8
  69. package/src/analytics/generated/events.json +1 -1
  70. package/src/api/types.ts +42 -3
  71. package/src/default/FacetGroupControl.tsx +339 -9
  72. package/src/default/FacetPanelPane.tsx +131 -19
  73. package/src/default/FilterChips.tsx +5 -2
  74. package/src/default/PartitionChips.tsx +204 -0
  75. package/src/default/PopularValues.tsx +151 -0
  76. package/src/default/RangeFilterRow.tsx +116 -1
  77. package/src/default/SearchPage.tsx +61 -2
  78. package/src/default/index.ts +27 -2
  79. package/src/headless/useFacetLabels.ts +7 -7
  80. package/src/i18n/es.ts +9 -0
  81. package/src/i18n/keys.ts +63 -0
  82. package/src/i18n/ru.ts +9 -0
  83. package/src/index.ts +13 -0
  84. package/src/state/facets.ts +259 -27
  85. package/src/state/ranges.ts +44 -0
  86. 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 { featureConfig, featureType } from "@stapel/attributes-react";
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
- /** 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,16 +147,61 @@ 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.
183
+ *
184
+ * Order matters, and it changed in one place after a live measurement:
130
185
  *
131
- * Order matters: a hierarchical facet is nested even when it is single-choice,
132
- * because losing the tree costs more than losing the pills.
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 { facetCoverage, orderFacetGroups } from "../state/facets.js";
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
- // Evidence order, the chip row's rule applied to the rail:
621
- // answered axes first, then the ones this corpus actually
622
- // fills. Schema order is the catalogue importer's, and an
623
- // imported catalogue's is alphabetical-by-accident.
624
- const drawable = orderFacetGroups(
625
- groups.filter((group) => group.options.length > 0)
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; here the answer's evidence
629
- // picks the rest: the top counted groups by coverage, and a
630
- // group the server never counted sums to zero, so the wall
631
- // of "not counted" rows starts as headers.
632
- const openByEvidence = new Set(
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(([group]) => group.slug)
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
- {listed.map((group) => (
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
- openByEvidence.has(group.slug)
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 { compareFacetsByEvidence } from "../state/facets.js";
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((group) => group.options.length > 0)
406
+ ? bag.state.data.filter(facetGroupIsDrawable)
404
407
  : [];
405
408
 
406
409
  const close = (): void => {