@stapel/search-react 0.22.0 → 0.24.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 (74) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/MODULE.md +12 -0
  3. package/README.md +122 -6
  4. package/dist/api/types.d.ts +13 -0
  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 +22 -4
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  9. package/dist/default/FacetGroupControl.js +85 -9
  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 +86 -2
  16. package/dist/default/FilterChips.d.ts.map +1 -1
  17. package/dist/default/FilterChips.js +210 -4
  18. package/dist/default/FilterChips.js.map +1 -1
  19. package/dist/default/PartitionChips.d.ts +14 -0
  20. package/dist/default/PartitionChips.d.ts.map +1 -1
  21. package/dist/default/PartitionChips.js +30 -4
  22. package/dist/default/PartitionChips.js.map +1 -1
  23. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  24. package/dist/default/RangeFilterRow.js +65 -11
  25. package/dist/default/RangeFilterRow.js.map +1 -1
  26. package/dist/default/SearchPage.d.ts +65 -1
  27. package/dist/default/SearchPage.d.ts.map +1 -1
  28. package/dist/default/SearchPage.js +47 -5
  29. package/dist/default/SearchPage.js.map +1 -1
  30. package/dist/default/index.d.ts +4 -4
  31. package/dist/default/index.d.ts.map +1 -1
  32. package/dist/default/index.js +3 -3
  33. package/dist/default/index.js.map +1 -1
  34. package/dist/i18n/es.d.ts.map +1 -1
  35. package/dist/i18n/es.js +9 -0
  36. package/dist/i18n/es.js.map +1 -1
  37. package/dist/i18n/keys.d.ts +39 -0
  38. package/dist/i18n/keys.d.ts.map +1 -1
  39. package/dist/i18n/keys.js +49 -0
  40. package/dist/i18n/keys.js.map +1 -1
  41. package/dist/i18n/ru.d.ts.map +1 -1
  42. package/dist/i18n/ru.js +9 -0
  43. package/dist/i18n/ru.js.map +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/state/facets.d.ts +64 -0
  49. package/dist/state/facets.d.ts.map +1 -1
  50. package/dist/state/facets.js +144 -0
  51. package/dist/state/facets.js.map +1 -1
  52. package/dist/state/ranges.d.ts +22 -0
  53. package/dist/state/ranges.d.ts.map +1 -1
  54. package/dist/state/ranges.js +30 -0
  55. package/dist/state/ranges.js.map +1 -1
  56. package/llms.txt +1 -1
  57. package/manifest.json +14 -1
  58. package/nav-manifest.json +1 -1
  59. package/package.json +7 -7
  60. package/src/analytics/generated/events.json +1 -1
  61. package/src/api/types.ts +13 -0
  62. package/src/default/FacetGroupControl.tsx +139 -14
  63. package/src/default/FacetPanelPane.tsx +131 -19
  64. package/src/default/FilterChips.tsx +313 -6
  65. package/src/default/PartitionChips.tsx +47 -4
  66. package/src/default/RangeFilterRow.tsx +116 -1
  67. package/src/default/SearchPage.tsx +94 -3
  68. package/src/default/index.ts +27 -3
  69. package/src/i18n/es.ts +9 -0
  70. package/src/i18n/keys.ts +50 -0
  71. package/src/i18n/ru.ts +9 -0
  72. package/src/index.ts +4 -0
  73. package/src/state/facets.ts +152 -0
  74. package/src/state/ranges.ts +44 -0
@@ -1,6 +1,42 @@
1
1
  /**
2
- * `<FilterChips>` — the phone's filter row: one horizontally scrolling line of
3
- * chips, each of which opens its OWN picker.
2
+ * `<FilterChips>` — the filter row, in its two modes.
3
+ *
4
+ * ## `mode="openers"` (the default): the phone's row
5
+ *
6
+ * One horizontally scrolling line of chips, each of which opens its OWN
7
+ * picker. Everything below this heading describes that mode.
8
+ *
9
+ * ## `mode="applied"`: the row a rail on screen needs
10
+ *
11
+ * Where the panel is already drawn — the desktop column — a row of openers is
12
+ * the wrong shape twice over: it prints the whole rail a second time, and no
13
+ * chip in it REMOVES anything without opening a modal over the results. A
14
+ * storefront hit exactly that (a 2377px filter column; picking two values left
15
+ * nothing between the header and the first card, and dropping one of them
16
+ * meant scrolling the rail until its button came back) and wrote its own row.
17
+ * This mode is that row, in the pair:
18
+ *
19
+ * - one chip per applied facet VALUE and per applied numeric range, never per
20
+ * axis — "Brand" with three values chosen is three chips, three removals;
21
+ * - each chip names the axis AND the value ("Brand: Bosch", "Price: from 100
22
+ * to 500"), because beside a dozen axes a bare value names nothing;
23
+ * - each chip is a real `<button>` whose press removes THAT constraint, and
24
+ * whose accessible name says so — not an antd `Tag closable`, whose close
25
+ * icon is a `<span>` with no tab stop, i.e. a constraint a keyboard can
26
+ * read and cannot drop;
27
+ * - the same clear-all the rail's footer runs, beside the chips instead of a
28
+ * column-height away;
29
+ * - nothing applied, nothing drawn. An empty band above the results is
30
+ * furniture, and this row's whole claim is that it is there only when it
31
+ * has something to say.
32
+ *
33
+ * Both modes read the SAME bag (`useFacetPanel`, `buildRangeGroups` over the
34
+ * page's own state) the rail reads, so no two surfaces can disagree about what
35
+ * is applied or about what a value is called — including the label path:
36
+ * the answer's `facet_labels` first, the category schema second, the raw slug
37
+ * marked as such third. Every applied chip stamps `data-label-source` and
38
+ * `data-value-label-source` so a storefront's test fails on a raw index term
39
+ * rather than eyeballing one.
4
40
  *
5
41
  * ## What it replaces, and why the replacement is not cosmetic
6
42
  *
@@ -133,8 +169,11 @@ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
133
169
  import { useSearchState } from "../headless/SearchStateProvider.js";
134
170
  import { buildRangeGroups } from "../state/ranges.js";
135
171
  import type { RangeGroup } from "../state/ranges.js";
136
- import { compareFacetsByEvidence } from "../state/facets.js";
137
- import type { FacetGroup } from "../state/facets.js";
172
+ import {
173
+ compareFacetsByEvidence,
174
+ facetGroupIsDrawable,
175
+ } from "../state/facets.js";
176
+ import type { FacetGroup, FacetLabelSource } from "../state/facets.js";
138
177
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
139
178
  import { FacetGroupControl } from "./FacetGroupControl.js";
140
179
  import { RangeFilterRow } from "./RangeFilterRow.js";
@@ -287,7 +326,11 @@ export function capChipRow(
287
326
  return { visible: specs.slice(0, cut), overflow: specs.length - cut };
288
327
  }
289
328
 
290
- export interface FilterChipsProps {
329
+ /** What the row is FOR — see this module's opening note. */
330
+ export type FilterChipsMode = "openers" | "applied";
331
+
332
+ /** What both modes read the search through. */
333
+ export interface FilterChipsCommonProps {
291
334
  /** The category's feature schema — the source of option labels, of which
292
335
  * slugs get a range chip, of which slugs are choosable at all, and of how
293
336
  * each group is drawn. */
@@ -299,6 +342,11 @@ export interface FilterChipsProps {
299
342
  * value cannot read one way on a chip and another way inside the sheet.
300
343
  */
301
344
  readonly resolveFacetLabels?: FacetLabelResolver;
345
+ }
346
+
347
+ export interface FilterChipsOpenerProps extends FilterChipsCommonProps {
348
+ /** The default. Every chip OPENS a picker; see the module note. */
349
+ readonly mode?: "openers";
302
350
  /**
303
351
  * The catalogue picker (`categories-react`), same slot the panel takes.
304
352
  *
@@ -332,6 +380,20 @@ export interface FilterChipsProps {
332
380
  readonly maxRowChips?: number | null;
333
381
  }
334
382
 
383
+ /**
384
+ * The applied row: one chip per constraint, each of which removes it.
385
+ *
386
+ * None of the opener props are here and that is the point — a row that draws
387
+ * only what is applied has no picker to open, no capped tail to stand in front
388
+ * of and no category chip (narrowing the category is a navigation, not a
389
+ * filter this row can drop).
390
+ */
391
+ export interface FilterChipsAppliedProps extends FilterChipsCommonProps {
392
+ readonly mode: "applied";
393
+ }
394
+
395
+ export type FilterChipsProps = FilterChipsOpenerProps | FilterChipsAppliedProps;
396
+
335
397
  /**
336
398
  * The label a chip carries: the group's name alone when nothing is chosen,
337
399
  * and the CHOICE when something is — "Brand" becomes "Bosch", "Brand, +2".
@@ -370,7 +432,22 @@ export function categoryLeaf(path: string): string | undefined {
370
432
  return /^\d+$/.test(leaf) ? undefined : leaf;
371
433
  }
372
434
 
435
+ /**
436
+ * The row, in whichever mode was asked for.
437
+ *
438
+ * Two components rather than one with a branch in it: the modes share the
439
+ * search they read and share nothing they DRAW, and a single body would have
440
+ * to run both sets of hooks on every render of either.
441
+ */
373
442
  export function FilterChips(props: FilterChipsProps): ReactElement | null {
443
+ return props.mode === "applied" ? (
444
+ <AppliedChipRow {...props} />
445
+ ) : (
446
+ <OpenerChipRow {...props} />
447
+ );
448
+ }
449
+
450
+ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
374
451
  const t = useT();
375
452
  const { state, setCategory } = useSearchState();
376
453
  const bag = useFacetPanel({
@@ -400,7 +477,7 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
400
477
  });
401
478
  const groups =
402
479
  bag.state.status === "ready"
403
- ? bag.state.data.filter((group) => group.options.length > 0)
480
+ ? bag.state.data.filter(facetGroupIsDrawable)
404
481
  : [];
405
482
 
406
483
  const close = (): void => {
@@ -641,6 +718,236 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
641
718
  );
642
719
  }
643
720
 
721
+ /* ── the applied row ────────────────────────────────────────────────────── */
722
+
723
+ type Translate = (key: string, params?: Record<string, unknown>) => string;
724
+
725
+ /** Which constraint one applied chip drops. */
726
+ export type AppliedChipTarget =
727
+ | { readonly kind: "facet"; readonly slug: string; readonly value: string }
728
+ | { readonly kind: "range"; readonly slug: string };
729
+
730
+ /** One applied constraint, resolved to the words it prints and what it drops. */
731
+ export interface AppliedChip {
732
+ readonly key: string;
733
+ readonly target: AppliedChipTarget;
734
+ /** What the AXIS is called, and which source named it. */
735
+ readonly name: string;
736
+ readonly nameSource: FacetLabelSource;
737
+ /** What the VALUE is called, and which source named it. */
738
+ readonly value: string;
739
+ readonly valueSource: FacetLabelSource;
740
+ }
741
+
742
+ /** The chip's own test hook — the constraint it drops, spelled out. */
743
+ export function appliedChipTestId(target: AppliedChipTarget): string {
744
+ return target.kind === "facet"
745
+ ? `search-applied-chip-${target.slug}-${target.value}`
746
+ : `search-applied-chip-range-${target.slug}`;
747
+ }
748
+
749
+ /**
750
+ * A numeric constraint as one phrase.
751
+ *
752
+ * The bounds print exactly as the URL carries them — the wire never promised a
753
+ * number and reformatting one would rewrite the link — except on a core MONEY
754
+ * axis, where the currency the answer's own cards carry turns "1000" into
755
+ * money, the same way the cards do.
756
+ */
757
+ export function rangeChipText(range: RangeGroup, t: Translate): string {
758
+ const one = (bound: string): string => {
759
+ const n = Number(bound);
760
+ if (range.currency !== undefined && Number.isFinite(n)) {
761
+ return new Intl.NumberFormat(undefined, {
762
+ style: "currency",
763
+ currency: range.currency,
764
+ maximumFractionDigits: 0,
765
+ }).format(n);
766
+ }
767
+ return range.unit === undefined ? bound : `${bound} ${range.unit}`;
768
+ };
769
+ if (range.from !== undefined && range.to !== undefined) {
770
+ return t(SEARCH_I18N_KEYS.filtersChipRangeBetween, {
771
+ from: one(range.from),
772
+ to: one(range.to),
773
+ });
774
+ }
775
+ if (range.from !== undefined) {
776
+ return t(SEARCH_I18N_KEYS.filtersChipRangeFrom, { from: one(range.from) });
777
+ }
778
+ return t(SEARCH_I18N_KEYS.filtersChipRangeTo, { to: one(range.to ?? "") });
779
+ }
780
+
781
+ /**
782
+ * Where a range axis got its NAME — the same three-source question the facet
783
+ * groups answer for themselves, asked of the other half of the model.
784
+ *
785
+ * A core axis is named by the answer declaring it (`facet_meta.core_ranges`)
786
+ * and this package's own catalogue; a category attribute is named by the
787
+ * schema; a slug the URL constrains that neither explains prints as itself,
788
+ * and that is `"none"` — the same honest bottom the rail marks.
789
+ */
790
+ export function rangeLabelSource(range: RangeGroup): FacetLabelSource {
791
+ if (range.core) return "server";
792
+ return range.feature === undefined ? "none" : "schema";
793
+ }
794
+
795
+ /**
796
+ * Every applied constraint of the current search, in the order the rail states
797
+ * them: the facet values first, in the order the groups came in, then the
798
+ * numeric ranges.
799
+ *
800
+ * Pure, and exported, because "one chip per VALUE" is the whole shape of the
801
+ * row — a build that collapsed three chosen brands into one chip would remove
802
+ * three filters with one press and no test that only counts chips would see
803
+ * it.
804
+ */
805
+ export function buildAppliedChips(input: {
806
+ readonly groups: readonly FacetGroup[];
807
+ readonly ranges: readonly RangeGroup[];
808
+ readonly t: Translate;
809
+ }): readonly AppliedChip[] {
810
+ const chips: AppliedChip[] = [];
811
+ for (const group of input.groups) {
812
+ for (const option of group.options) {
813
+ if (!option.selected) continue;
814
+ chips.push({
815
+ key: `f.${group.slug}=${option.value}`,
816
+ target: { kind: "facet", slug: group.slug, value: option.value },
817
+ name: group.label,
818
+ nameSource: group.labelSource,
819
+ value: option.label,
820
+ valueSource: option.labelSource,
821
+ });
822
+ }
823
+ }
824
+ for (const range of input.ranges) {
825
+ if (!range.active) continue;
826
+ const source = rangeLabelSource(range);
827
+ chips.push({
828
+ key: `r.${range.slug}`,
829
+ target: { kind: "range", slug: range.slug },
830
+ name: range.label,
831
+ nameSource: source,
832
+ value: rangeChipText(range, input.t),
833
+ // The bounds are the person's own numbers — nobody had to name them.
834
+ valueSource: source,
835
+ });
836
+ }
837
+ return chips;
838
+ }
839
+
840
+ /** The applied row wraps: it stands beside a rail, not on a 390px scroller,
841
+ * and a constraint pushed off the line is a constraint with no control. */
842
+ const APPLIED_ROW: CSSProperties = {
843
+ display: "flex",
844
+ alignItems: "center",
845
+ gap: spacing[2],
846
+ flexWrap: "wrap",
847
+ // Room for the focus ring of the first chip, which a flush edge clips.
848
+ paddingBlock: spacing[1],
849
+ };
850
+
851
+ function AppliedChipRow(props: FilterChipsAppliedProps): ReactElement | null {
852
+ const t = useT();
853
+ const { state } = useSearchState();
854
+ const bag = useFacetPanel({
855
+ ...(props.categoryFeatures !== undefined
856
+ ? { categoryFeatures: props.categoryFeatures }
857
+ : {}),
858
+ ...(props.locale !== undefined ? { locale: props.locale } : {}),
859
+ ...(props.resolveFacetLabels !== undefined
860
+ ? { resolveFacetLabels: props.resolveFacetLabels }
861
+ : {}),
862
+ });
863
+ const ranges = buildRangeGroups({
864
+ state,
865
+ ...(props.categoryFeatures !== undefined
866
+ ? { categoryFeatures: props.categoryFeatures }
867
+ : {}),
868
+ coreRanges: bag.coreRanges,
869
+ ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
870
+ t,
871
+ });
872
+
873
+ /* NOTHING IS DRAWN UNTIL THE ANSWER LANDS.
874
+ *
875
+ * Both halves of a chip are named by the envelope: the heading path
876
+ * (`facet_labels[<slug>]`), and — for a range — the answer's own declaration
877
+ * of which axes are CORE columns (`facet_meta.core_ranges`). Before it, an
878
+ * `r.price` the URL carries is a slug this row has no name for, so it would
879
+ * draw a chip captioned with that slug and rename it a moment later. A
880
+ * caption that changes under the reader is worse than a caption that
881
+ * arrives; the panel is the control in the meantime.
882
+ */
883
+ const answered = bag.state.status === "ready";
884
+ const chips = buildAppliedChips({
885
+ groups: answered ? bag.state.data : [],
886
+ ranges: answered ? ranges : [],
887
+ t,
888
+ });
889
+ if (chips.length === 0) return null;
890
+
891
+ const remove = (target: AppliedChipTarget): void => {
892
+ if (target.kind === "facet") bag.toggle(target.slug, target.value);
893
+ else bag.setRange(target.slug, null);
894
+ };
895
+
896
+ return (
897
+ <div
898
+ style={APPLIED_ROW}
899
+ role="group"
900
+ aria-label={t(SEARCH_I18N_KEYS.filtersAppliedLabel)}
901
+ data-testid="search-applied-chips"
902
+ >
903
+ {chips.map((chip) => {
904
+ const text = t(SEARCH_I18N_KEYS.filtersChipPair, {
905
+ name: chip.name,
906
+ value: chip.value,
907
+ });
908
+ return (
909
+ <Button
910
+ key={chip.key}
911
+ shape="round"
912
+ size="small"
913
+ style={CHIP}
914
+ data-testid={appliedChipTestId(chip.target)}
915
+ /* The two halves of the caption, marked with what named each of
916
+ them. `[data-label-source="none"]` is the assertion that a raw
917
+ index term ever reached this row. */
918
+ data-label-source={chip.nameSource}
919
+ data-value-label-source={chip.valueSource}
920
+ data-analytics="none"
921
+ data-analytics-reason="dropping a filter is a read, not a flow step"
922
+ aria-label={t(SEARCH_I18N_KEYS.filtersChipRemove, { filter: text })}
923
+ onClick={() => {
924
+ remove(chip.target);
925
+ }}
926
+ >
927
+ <span>{text}</span>
928
+ <span aria-hidden="true">{"×"}</span>
929
+ </Button>
930
+ );
931
+ })}
932
+ {/* The SAME action the rail's own footer runs, beside the chips instead
933
+ of a column-height down the page. */}
934
+ <Button
935
+ type="link"
936
+ size="small"
937
+ style={{ paddingInline: 0 }}
938
+ data-testid="search-applied-chips-clear"
939
+ data-analytics="none"
940
+ data-analytics-reason="dropping the filters is a read, not a flow step"
941
+ onClick={() => {
942
+ bag.clearAll();
943
+ }}
944
+ >
945
+ {t(SEARCH_I18N_KEYS.facetsClearAll, { count: bag.activeFilters })}
946
+ </Button>
947
+ </div>
948
+ );
949
+ }
950
+
644
951
  /** The mark on the "all filters" chip: something is applied. Not a count —
645
952
  * the counts are on the chips beside it, and a number inside a 32px circle is
646
953
  * a number nobody reads. */
@@ -35,7 +35,7 @@ import type {
35
35
  } from "react";
36
36
  import { Button } from "antd";
37
37
  import { useT } from "@stapel/core";
38
- import { radii, spacing } from "@stapel/tokens";
38
+ import { cssVar, radii, spacing } from "@stapel/tokens";
39
39
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
40
40
 
41
41
  /** One child of a partitioned category. `path` is the slash-joined id path
@@ -58,6 +58,20 @@ export interface PartitionChipsProps {
58
58
  readonly allLabel?: ReactNode;
59
59
  /** The row's accessible name. Defaults to `search.partition.label`. */
60
60
  readonly label?: string;
61
+ /**
62
+ * Which shape the row takes. `"chips"` (the default) is the phone's: a
63
+ * wrapping row of rounded pills above the feed. `"segmented"` is the
64
+ * desktop RAIL's — one joined control under the axis's own label, which is
65
+ * how the reference classified draws the same choice (a car-type row:
66
+ * all, used, new) and what a 280px column has room for.
67
+ *
68
+ * The SEMANTICS do not vary with it. Both are the same `radiogroup` with
69
+ * the same roving tabindex and the same arrow keys — a segmented look is a
70
+ * border-radius decision, and swapping in a component that draws joined
71
+ * cells by giving up "exactly one of these is true" would trade the
72
+ * accessible half of this control for the visible half.
73
+ */
74
+ readonly variant?: "chips" | "segmented";
61
75
  }
62
76
 
63
77
  const CHIP: CSSProperties = { borderRadius: radii.full };
@@ -68,6 +82,33 @@ const ROW: CSSProperties = {
68
82
  gap: spacing[2],
69
83
  };
70
84
 
85
+ /**
86
+ * The segmented row: one joined control, no gaps, the group's own outline.
87
+ *
88
+ * `gap: 0` plus a shared border is what makes three buttons read as one
89
+ * control — the thing a rail needs, because a wrapping pill row in a 280px
90
+ * column is two ragged lines. `overflow: hidden` clips the cells' own corners
91
+ * to the group's radius so the ends are round and the joins are square.
92
+ */
93
+ const SEGMENTED_ROW: CSSProperties = {
94
+ display: "flex",
95
+ gap: 0,
96
+ inlineSize: "100%",
97
+ border: `1px solid ${cssVar("border")}`,
98
+ borderRadius: cssVar("radius-md"),
99
+ overflow: "hidden",
100
+ };
101
+
102
+ /** One cell of the segmented row: an equal share of the width, square joins,
103
+ * no border of its own — the group draws the outline. */
104
+ const SEGMENTED_CELL: CSSProperties = {
105
+ flex: "1 1 0",
106
+ minInlineSize: 0,
107
+ borderRadius: 0,
108
+ borderInline: "none",
109
+ borderBlock: "none",
110
+ };
111
+
71
112
  /** The row's cells, as `[value, label]` — the parent first, then the
72
113
  * children in catalogue order. */
73
114
  function cells(
@@ -122,9 +163,11 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
122
163
  const active = options.findIndex(([value]) => value === props.value);
123
164
  const stop = active >= 0 ? active : 0;
124
165
 
166
+ const segmented = props.variant === "segmented";
125
167
  return (
126
168
  <div
127
- style={ROW}
169
+ style={segmented ? SEGMENTED_ROW : ROW}
170
+ data-variant={segmented ? "segmented" : "chips"}
128
171
  ref={row}
129
172
  role="radiogroup"
130
173
  aria-label={props.label ?? t(SEARCH_I18N_KEYS.partitionLabel)}
@@ -136,14 +179,14 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
136
179
  <Button
137
180
  key={value ?? "__all__"}
138
181
  size="small"
139
- shape="round"
182
+ {...(segmented ? {} : { shape: "round" as const })}
140
183
  type={selected ? "primary" : "default"}
141
184
  role="radio"
142
185
  aria-checked={selected}
143
186
  // Roving tabindex: the row is ONE Tab stop and it lands on the
144
187
  // chosen chip, not on the first of eight.
145
188
  tabIndex={index === stop ? 0 : -1}
146
- style={CHIP}
189
+ style={segmented ? SEGMENTED_CELL : CHIP}
147
190
  data-testid={`partition-chip-${value ?? "all"}`}
148
191
  data-analytics="none"
149
192
  data-analytics-reason="choosing a section is a read, not a flow step"
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { useRef, useState } from "react";
19
19
  import type { ReactElement } from "react";
20
- import { Button, Flex, InputNumber, Typography } from "antd";
20
+ import { Button, Flex, InputNumber, Select, Typography } from "antd";
21
21
  import { actionAvailable, actionBlocked, useFormat, useT } from "@stapel/core";
22
22
  import type { ActionAvailability } from "@stapel/core";
23
23
  import { GatedButton } from "@stapel/tokens-antd/skin";
@@ -70,6 +70,100 @@ function boundSuffix(
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * One bound of a PICKER axis — a bounded integer small enough to choose from
75
+ * (`RangeGroup.picker`), which on a cars leaf is the year.
76
+ *
77
+ * The founder's walk of the live page put it plainly: the year was a bare
78
+ * number field. A year is not a quantity a person computes, it is one of a
79
+ * hundred-odd values, and every classified draws it as two pickers.
80
+ *
81
+ * Typing still works, and the rule is the same one `attributes-react` is
82
+ * building for a bounded integer field: what a person types is a value, not a
83
+ * query. While the typed text IS a valid value inside the bounds the list
84
+ * narrows to it and gets out of the way; the moment it is not — a letter, a
85
+ * year before the catalogue's own floor — the full list comes back WITH the
86
+ * bounds said in words, because "1899" silently doing nothing is the defect
87
+ * this replaces.
88
+ */
89
+ function BoundPicker(props: {
90
+ readonly group: RangeGroup;
91
+ readonly bound: "from" | "to";
92
+ readonly value: string;
93
+ readonly onChange: (value: string) => void;
94
+ readonly onCommit: () => void;
95
+ }): ReactElement {
96
+ const t = useT();
97
+ const { group } = props;
98
+ const [typed, setTyped] = useState("");
99
+ const values = group.picker ?? [];
100
+ const min = group.min ?? values[values.length - 1];
101
+ const max = group.max ?? values[0];
102
+ const query = typed.trim();
103
+ const asNumber = Number(query);
104
+ // "Valid" is the whole rule: a number, whole, and inside the axis's own
105
+ // bounds. Anything else is a query that matched nothing, not a value.
106
+ const valid =
107
+ query !== "" &&
108
+ /^-?\d+$/.test(query) &&
109
+ Number.isInteger(asNumber) &&
110
+ (min === undefined || asNumber >= min) &&
111
+ (max === undefined || asNumber <= max);
112
+ const listed = valid
113
+ ? values.filter((value) => String(value).startsWith(query))
114
+ : values;
115
+ return (
116
+ <Flex vertical gap={spacing[1]}>
117
+ <Select
118
+ showSearch
119
+ allowClear
120
+ value={props.value === "" ? null : props.value}
121
+ searchValue={typed}
122
+ // The list is already narrowed above, by the rule this control is
123
+ // about; antd's own substring filter would narrow it a second time
124
+ // and by a different rule.
125
+ filterOption={false}
126
+ placeholder={t(
127
+ props.bound === "from"
128
+ ? SEARCH_I18N_KEYS.facetsRangeFrom
129
+ : SEARCH_I18N_KEYS.facetsRangeTo
130
+ )}
131
+ aria-label={t(
132
+ props.bound === "from"
133
+ ? SEARCH_I18N_KEYS.facetsRangeFromAria
134
+ : SEARCH_I18N_KEYS.facetsRangeToAria,
135
+ { feature: group.label }
136
+ )}
137
+ data-testid={`facet-range-${group.slug}-${props.bound}`}
138
+ style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
139
+ options={listed.map((value) => ({
140
+ value: String(value),
141
+ label: String(value),
142
+ }))}
143
+ onSearch={setTyped}
144
+ onChange={(next: string | null) => {
145
+ setTyped("");
146
+ props.onChange(next ?? "");
147
+ }}
148
+ onBlur={props.onCommit}
149
+ />
150
+ {/* Said only when the typing has gone outside the axis — a hint over an
151
+ untouched field is chrome. */}
152
+ {query !== "" && !valid && (
153
+ <Typography.Text
154
+ type="secondary"
155
+ data-testid={`facet-range-${group.slug}-${props.bound}-bounds`}
156
+ >
157
+ {t(SEARCH_I18N_KEYS.facetsRangeBounds, {
158
+ min: String(min ?? ""),
159
+ max: String(max ?? ""),
160
+ })}
161
+ </Typography.Text>
162
+ )}
163
+ </Flex>
164
+ );
165
+ }
166
+
73
167
  export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
74
168
  const t = useT();
75
169
  const format = useFormat();
@@ -138,6 +232,25 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
138
232
  {suffix === undefined ? group.label : `${group.label}, ${suffix}`}
139
233
  </Typography.Text>
140
234
  <Flex gap={spacing[2]} align="center" wrap>
235
+ {group.picker !== undefined ? (
236
+ <>
237
+ <BoundPicker
238
+ group={group}
239
+ bound="from"
240
+ value={from}
241
+ onChange={setFrom}
242
+ onCommit={commit}
243
+ />
244
+ <BoundPicker
245
+ group={group}
246
+ bound="to"
247
+ value={to}
248
+ onChange={setTo}
249
+ onCommit={commit}
250
+ />
251
+ </>
252
+ ) : (
253
+ <>
141
254
  <InputNumber
142
255
  value={from === "" ? null : Number(from)}
143
256
  placeholder={t(SEARCH_I18N_KEYS.facetsRangeFrom)}
@@ -172,6 +285,8 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
172
285
  }}
173
286
  onPressEnter={commit}
174
287
  />
288
+ </>
289
+ )}
175
290
  {/* Primary when there is something to apply, secondary when there is
176
291
  not. It used to be the other way round — filled over two empty
177
292
  fields, ghosted the moment the person had typed the numbers the