@stapel/search-react 0.22.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 (73) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/MODULE.md +12 -0
  3. package/README.md +84 -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.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 +14 -0
  19. package/dist/default/PartitionChips.d.ts.map +1 -1
  20. package/dist/default/PartitionChips.js +30 -4
  21. package/dist/default/PartitionChips.js.map +1 -1
  22. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  23. package/dist/default/RangeFilterRow.js +65 -11
  24. package/dist/default/RangeFilterRow.js.map +1 -1
  25. package/dist/default/SearchPage.d.ts +49 -0
  26. package/dist/default/SearchPage.d.ts.map +1 -1
  27. package/dist/default/SearchPage.js +43 -2
  28. package/dist/default/SearchPage.js.map +1 -1
  29. package/dist/default/index.d.ts +2 -2
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +2 -2
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/i18n/es.d.ts.map +1 -1
  34. package/dist/i18n/es.js +3 -0
  35. package/dist/i18n/es.js.map +1 -1
  36. package/dist/i18n/keys.d.ts +22 -0
  37. package/dist/i18n/keys.d.ts.map +1 -1
  38. package/dist/i18n/keys.js +25 -0
  39. package/dist/i18n/keys.js.map +1 -1
  40. package/dist/i18n/ru.d.ts.map +1 -1
  41. package/dist/i18n/ru.js +3 -0
  42. package/dist/i18n/ru.js.map +1 -1
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/state/facets.d.ts +64 -0
  48. package/dist/state/facets.d.ts.map +1 -1
  49. package/dist/state/facets.js +144 -0
  50. package/dist/state/facets.js.map +1 -1
  51. package/dist/state/ranges.d.ts +22 -0
  52. package/dist/state/ranges.d.ts.map +1 -1
  53. package/dist/state/ranges.js +30 -0
  54. package/dist/state/ranges.js.map +1 -1
  55. package/llms.txt +1 -1
  56. package/manifest.json +8 -1
  57. package/nav-manifest.json +1 -1
  58. package/package.json +7 -7
  59. package/src/analytics/generated/events.json +1 -1
  60. package/src/api/types.ts +13 -0
  61. package/src/default/FacetGroupControl.tsx +139 -14
  62. package/src/default/FacetPanelPane.tsx +131 -19
  63. package/src/default/FilterChips.tsx +5 -2
  64. package/src/default/PartitionChips.tsx +47 -4
  65. package/src/default/RangeFilterRow.tsx +116 -1
  66. package/src/default/SearchPage.tsx +61 -2
  67. package/src/default/index.ts +12 -2
  68. package/src/i18n/es.ts +3 -0
  69. package/src/i18n/keys.ts +25 -0
  70. package/src/i18n/ru.ts +3 -0
  71. package/src/index.ts +4 -0
  72. package/src/state/facets.ts +152 -0
  73. package/src/state/ranges.ts +44 -0
@@ -66,7 +66,7 @@ import { useState } from "react";
66
66
  import type { CSSProperties, ReactElement } from "react";
67
67
  import { Button, Checkbox, Flex, Input, Typography } from "antd";
68
68
  import { useT } from "@stapel/core";
69
- import { radii, spacing } from "@stapel/tokens";
69
+ import { controls, cssVar, radii, spacing } from "@stapel/tokens";
70
70
  import {
71
71
  VOCABULARY_BACKED_TYPES,
72
72
  featureConfig,
@@ -181,18 +181,28 @@ export function isDictionaryFacet(group: FacetGroup): boolean {
181
181
  /**
182
182
  * Which of the four shapes a group takes.
183
183
  *
184
- * Order matters: a hierarchical facet is nested even when it is single-choice,
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.
184
+ * Order matters, and it changed in one place after a live measurement:
185
+ *
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.
188
198
  */
189
199
  export function facetGroupShape(group: FacetGroup): FacetGroupShape {
190
200
  const feature = group.feature;
191
201
  if (feature !== undefined && featureType(feature) === "hierarchical_select") {
192
202
  return "nested";
193
203
  }
194
- if (singleChoice(feature)) return "segmented";
195
- return isDictionaryFacet(group) ? "dictionary" : "checkbox";
204
+ if (isDictionaryFacet(group)) return "dictionary";
205
+ return singleChoice(feature) ? "segmented" : "checkbox";
196
206
  }
197
207
 
198
208
  /**
@@ -519,6 +529,105 @@ function DictionaryBody(props: {
519
529
  );
520
530
  }
521
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
+
522
631
  export interface FacetGroupControlProps {
523
632
  readonly group: FacetGroup;
524
633
  readonly onToggle: (slug: string, value: string) => void;
@@ -538,6 +647,14 @@ export interface FacetGroupControlProps {
538
647
  /** Whether a `collapsible` group STARTS open. Default `true`. The initial
539
648
  * value only — the person owns the state after the first click. */
540
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";
541
658
  }
542
659
 
543
660
  export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
@@ -618,13 +735,21 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
618
735
  {/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
619
736
  checkboxes are still a hundred stops for a screen reader, and the
620
737
  measured rail held 118 of them. */}
621
- {open && shape === "dictionary" && (
622
- <DictionaryBody
623
- group={group}
624
- onToggle={props.onToggle}
625
- visible={limit ?? FACET_VISIBLE_OPTIONS}
626
- />
627
- )}
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
+ ))}
628
753
 
629
754
  {open && shape !== "dictionary" && (
630
755
  <>
@@ -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 => {
@@ -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