@stapel/search-react 0.27.0 → 0.29.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 (70) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +55 -3
  3. package/dist/default/FacetGroupControl.d.ts +33 -6
  4. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  5. package/dist/default/FacetGroupControl.js +167 -4
  6. package/dist/default/FacetGroupControl.js.map +1 -1
  7. package/dist/default/FacetPanelPane.d.ts +5 -3
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +1 -1
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/OtherCategoriesLine.d.ts +21 -0
  12. package/dist/default/OtherCategoriesLine.d.ts.map +1 -1
  13. package/dist/default/OtherCategoriesLine.js +28 -6
  14. package/dist/default/OtherCategoriesLine.js.map +1 -1
  15. package/dist/default/SearchPage.d.ts +14 -7
  16. package/dist/default/SearchPage.d.ts.map +1 -1
  17. package/dist/default/SearchPage.js +5 -3
  18. package/dist/default/SearchPage.js.map +1 -1
  19. package/dist/default/SearchResultsPane.d.ts +4 -1
  20. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  21. package/dist/default/SearchResultsPane.js +2 -0
  22. package/dist/default/SearchResultsPane.js.map +1 -1
  23. package/dist/default/index.d.ts +2 -2
  24. package/dist/default/index.d.ts.map +1 -1
  25. package/dist/default/index.js +1 -1
  26. package/dist/default/index.js.map +1 -1
  27. package/dist/headless/FacetPanel.d.ts +9 -0
  28. package/dist/headless/FacetPanel.d.ts.map +1 -1
  29. package/dist/headless/FacetPanel.js +1 -0
  30. package/dist/headless/FacetPanel.js.map +1 -1
  31. package/dist/headless/SearchStateProvider.d.ts +38 -1
  32. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  33. package/dist/headless/SearchStateProvider.js +70 -19
  34. package/dist/headless/SearchStateProvider.js.map +1 -1
  35. package/dist/i18n/es.d.ts.map +1 -1
  36. package/dist/i18n/es.js +3 -0
  37. package/dist/i18n/es.js.map +1 -1
  38. package/dist/i18n/keys.d.ts +15 -0
  39. package/dist/i18n/keys.d.ts.map +1 -1
  40. package/dist/i18n/keys.js +18 -0
  41. package/dist/i18n/keys.js.map +1 -1
  42. package/dist/i18n/ru.d.ts.map +1 -1
  43. package/dist/i18n/ru.js +3 -0
  44. package/dist/i18n/ru.js.map +1 -1
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/state/urlState.d.ts +26 -1
  50. package/dist/state/urlState.d.ts.map +1 -1
  51. package/dist/state/urlState.js +13 -4
  52. package/dist/state/urlState.js.map +1 -1
  53. package/llms.txt +1 -1
  54. package/manifest.json +9 -2
  55. package/nav-manifest.json +1 -1
  56. package/package.json +11 -11
  57. package/src/analytics/generated/events.json +1 -1
  58. package/src/default/FacetGroupControl.tsx +278 -22
  59. package/src/default/FacetPanelPane.tsx +6 -3
  60. package/src/default/OtherCategoriesLine.tsx +52 -4
  61. package/src/default/SearchPage.tsx +25 -9
  62. package/src/default/SearchResultsPane.tsx +10 -1
  63. package/src/default/index.ts +2 -0
  64. package/src/headless/FacetPanel.tsx +10 -0
  65. package/src/headless/SearchStateProvider.tsx +99 -20
  66. package/src/i18n/es.ts +3 -0
  67. package/src/i18n/keys.ts +18 -0
  68. package/src/i18n/ru.ts +3 -0
  69. package/src/index.ts +4 -0
  70. package/src/state/urlState.ts +35 -4
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.27.0",
4
+ "version": "0.29.0",
5
5
  "backend": {
6
6
  "module": "stapel-search",
7
7
  "contract": ">=0.14 <0.15"
@@ -811,9 +811,12 @@
811
811
  "search.facets.approximate",
812
812
  "search.facets.clear",
813
813
  "search.facets.clear_all",
814
+ "search.facets.dictionary_all_values",
814
815
  "search.facets.dictionary_any",
815
816
  "search.facets.dictionary_chosen",
817
+ "search.facets.dictionary_done",
816
818
  "search.facets.dictionary_empty",
819
+ "search.facets.dictionary_recommended",
817
820
  "search.facets.dictionary_search",
818
821
  "search.facets.drill_down_hint",
819
822
  "search.facets.empty",
@@ -941,6 +944,7 @@
941
944
  ],
942
945
  "exports": {
943
946
  "runtime": [
947
+ "DEFAULT_HISTORY_MODE",
944
948
  "EMPTY_FACET_KEYS",
945
949
  "FACETABLE_FEATURE_TYPES",
946
950
  "FACET_PLAN_EVIDENCE",
@@ -1055,6 +1059,7 @@
1055
1059
  "FacetPanelBag",
1056
1060
  "FacetSelection",
1057
1061
  "FacetWithheldGroup",
1062
+ "HistoryMode",
1058
1063
  "OtherCategoriesBag",
1059
1064
  "OtherCategoryRow",
1060
1065
  "ParseSearchStateOptions",
@@ -1078,6 +1083,7 @@
1078
1083
  "SearchGeo",
1079
1084
  "SearchGeoBox",
1080
1085
  "SearchGeoCenter",
1086
+ "SearchHistoryKind",
1081
1087
  "SearchI18nKey",
1082
1088
  "SearchImageResolver",
1083
1089
  "SearchItem",
@@ -1100,7 +1106,8 @@
1100
1106
  "SuggestParams",
1101
1107
  "SuggestResponse",
1102
1108
  "UseSearchBoxOptions",
1103
- "UseSearchCountOptions"
1109
+ "UseSearchCountOptions",
1110
+ "WriteSearchStateDefaults"
1104
1111
  ]
1105
1112
  }
1106
1113
  }
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/search-react",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "entries": [
5
5
  {
6
6
  "id": "search.results",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/search-react",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,14 +52,14 @@
52
52
  ],
53
53
  "size-limit": [
54
54
  {
55
- "name": "index — the headless pair (client + hooks + URL codec + facet model incl. the facetability rule, the label-source order, the across-alphabets prefix matcher, the SCHEMA-order comparator that puts a category's required axes first and the bounded-integer picker list): no antd, no react-router. Raised 12 → 12.5 KB for the rail-order comparator, the shared drawability predicate (one rule for the rail and the chip row) and the picker bounds. Raised 12.5 → 13 KB for `useOtherCategories`, which reads the sections an answer is made of out of `facet_meta.categories` — the read that replaced a storefront's second request, then 13 → 13.5 KB for the SHORT FEATURE KEYS: the two-direction `url_key` map (built with the server's own collision rules, so an ambiguous short form keeps the slug on both sides), its publication seam from the answer up to the state provider, and the vocabulary-backed predicate the drawability rule and the dictionary shape now share",
55
+ "name": "index — the headless pair (client + hooks + URL codec + facet model incl. the facetability rule, the label-source order, the across-alphabets prefix matcher, the SCHEMA-order comparator that puts a category's required axes first and the bounded-integer picker list): no antd, no react-router. Raised 12 → 12.5 KB for the rail-order comparator, the shared drawability predicate (one rule for the rail and the chip row) and the picker bounds. Raised 12.5 → 13 KB for `useOtherCategories`, which reads the sections an answer is made of out of `facet_meta.categories` — the read that replaced a storefront's second request, then 13 → 13.5 KB for the SHORT FEATURE KEYS: the two-direction `url_key` map (built with the server's own collision rules, so an ambiguous short form keeps the slug on both sides), its publication seam from the answer up to the state provider, and the vocabulary-backed predicate the drawability rule and the dictionary shape now share, then 13.5 → 13.75 KB for the documented history policy (`DEFAULT_HISTORY_MODE`, one push/replace table every mutator follows instead of an ad hoc boolean per call) and the codec omitting `type`/`sort`/`limit` from the address when they equal the default a host declared",
56
56
  "path": "dist/index.js",
57
- "limit": "13.5 KB"
57
+ "limit": "13.75 KB"
58
58
  },
59
59
  {
60
- "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere",
60
+ "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab, then 28.5 → 29.25 KB for the phone's dictionary SHEET — a trigger row over the shared `SkinPickerSheet` with a recommended band, an alphabetical rest paged fifty at a time and a draft committed in one write, which is the control the composer's vocabulary picker already was while the buyer got a wall of checkboxes",
61
61
  "path": "dist/default/index.js",
62
- "limit": "27.75 KB"
62
+ "limit": "29.25 KB"
63
63
  },
64
64
  {
65
65
  "name": "router — the react-router binding is opt-in; the main entry must never pull a router",
@@ -67,9 +67,9 @@
67
67
  "limit": "1 KB"
68
68
  },
69
69
  {
70
- "name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry",
70
+ "name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry. Raised 4.75 → 5 KB for the dictionary sheet's three headings",
71
71
  "path": "dist/i18n/ru.js",
72
- "limit": "4.75 KB"
72
+ "limit": "5 KB"
73
73
  },
74
74
  {
75
75
  "name": "i18n/es — the Spanish bundle is opt-in, not carried by the main entry. Raised 3.5 → 3.75 KB for the three strings of the other-categories line",
@@ -81,7 +81,7 @@
81
81
  "@stapel/attributes-react": ">=0.4.0",
82
82
  "@stapel/core": ">=0.18.1 <1.0.0",
83
83
  "@stapel/image": ">=0.3.0",
84
- "@stapel/tokens-antd": ">=0.11.0",
84
+ "@stapel/tokens-antd": ">=0.12.0",
85
85
  "@tanstack/react-query": "^5.0.0",
86
86
  "antd": ">=5.20.0 <7",
87
87
  "react": ">=19",
@@ -116,11 +116,11 @@
116
116
  "typescript": "^5.8.3",
117
117
  "vitest": "^3.2.4",
118
118
  "@stapel/core": "^0.25.1",
119
- "@stapel/attributes-react": "^0.16.0",
120
- "@stapel/showcase": "^0.3.0",
121
119
  "@stapel/image": "^0.4.2",
122
120
  "@stapel/tokens": "^0.7.0",
123
- "@stapel/tokens-antd": "^0.17.0"
121
+ "@stapel/attributes-react": "^0.16.0",
122
+ "@stapel/tokens-antd": "^0.17.0",
123
+ "@stapel/showcase": "^0.3.0"
124
124
  },
125
125
  "engines": {
126
126
  "node": ">=22"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.27.0",
4
+ "version": "0.29.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -62,10 +62,12 @@
62
62
  * them visible as before: folding everything would leave a heading over
63
63
  * nothing. Chosen options are always visible, wherever their count went.
64
64
  */
65
- import { useState } from "react";
65
+ import { useMemo, 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 { SkinPickerSheet } from "@stapel/tokens-antd/skin";
70
+ import type { PickerGroup, PickerOption } from "@stapel/tokens-antd/skin";
69
71
  import { controls, cssVar, radii, spacing } from "@stapel/tokens";
70
72
  import { featureConfig, featureType } from "@stapel/attributes-react";
71
73
  import type { FeatureDef } from "@stapel/attributes-react";
@@ -632,6 +634,246 @@ function DictionaryField(props: {
632
634
  );
633
635
  }
634
636
 
637
+ /**
638
+ * How many values the sheet's «All values» band adds per page.
639
+ *
640
+ * Fifty is the vocabulary endpoint's own page size, and it is the number past
641
+ * which a phone list stops being scrolled and starts being searched. Reaching
642
+ * the end of the list asks for the next fifty, so a 418-make level is a
643
+ * scroll rather than 418 mounted rows.
644
+ */
645
+ export const FACET_SHEET_PAGE = 50;
646
+
647
+ /** One facet option as the shared picker reads it. The count rides along as
648
+ * the row's second line: a filter without its remaining count is the
649
+ * drill-down facet turned naive, and that rule does not stop at a sheet. */
650
+ function pickerRow(option: FacetOption, notCounted: string): PickerOption {
651
+ return {
652
+ value: option.value,
653
+ label: option.label,
654
+ description: option.count === null ? notCounted : String(option.count),
655
+ };
656
+ }
657
+
658
+ /**
659
+ * A dictionary group on a PHONE: a trigger row, and a nested sheet.
660
+ *
661
+ * ── What the buyer was looking at ─────────────────────────────────────────
662
+ *
663
+ * The composer's own vocabulary picker (`@stapel/attributes-react`'s
664
+ * `ref_select` editor) is a trigger that opens a sheet with a search box, a
665
+ * recommended band and the rest — zero checkboxes. The buyer's filter sheet
666
+ * drew the SAME axis as a wall of eight checkboxes with a "Find a value" box
667
+ * and a "Show all (38)" under it, and no way to say "any". Two halves of one
668
+ * product teaching two different gestures for one dictionary is the defect;
669
+ * this mode is the half that moved.
670
+ *
671
+ * ── Why the shared `SkinPickerSheet` and not a local one ──────────────────
672
+ *
673
+ * It is the same component the composer's picker draws: the search box pinned
674
+ * above the list, the checkmarks, the commit button above the home indicator,
675
+ * the swipe/Esc/back dismissal, the skeleton and the empty arm. A pair that
676
+ * re-derives that gets a near-miss of it, which is exactly how the two halves
677
+ * drifted the first time.
678
+ *
679
+ * ── The two bands ─────────────────────────────────────────────────────────
680
+ *
681
+ * «Recommended» is the busiest values BY COUNT — the answer's own evidence,
682
+ * capped at {@link FACET_VISIBLE_OPTIONS} — with the chosen values in front
683
+ * of it, because a filter a person cannot see is a filter they cannot remove.
684
+ * «All values» is everything else, alphabetically, a page at a time. Once
685
+ * something is typed the bands collapse into one: a "Recommended" heading
686
+ * over rows that do not answer the box is the stale-list defect wearing a
687
+ * hat.
688
+ *
689
+ * The box filters LOCALLY and across alphabets (`translitPrefixMatch`, the
690
+ * same matcher the desktop field uses), so a Cyrillic spelling of a Latin
691
+ * make finds it. Nothing here touches the URL: what a person typed to FIND a
692
+ * filter is not part of the search they would share. The COMMIT does — the
693
+ * whole draft is written to the slug's URL key at once.
694
+ */
695
+ function DictionarySheet(props: {
696
+ readonly group: FacetGroup;
697
+ readonly onSetValues: (slug: string, values: readonly string[]) => void;
698
+ }): ReactElement {
699
+ const t = useT();
700
+ const { group } = props;
701
+ const [open, setOpen] = useState(false);
702
+ const [needle, setNeedle] = useState("");
703
+ const [page, setPage] = useState(FACET_SHEET_PAGE);
704
+
705
+ const chosen = group.options.filter((option) => option.selected);
706
+ const query = needle.trim();
707
+
708
+ const { groups, total } = useMemo(() => {
709
+ const notCounted = t(SEARCH_I18N_KEYS.facetsNotCounted);
710
+ const hit = (option: FacetOption): boolean =>
711
+ query === "" ||
712
+ translitPrefixMatch(query, option.label) ||
713
+ translitPrefixMatch(query, option.value);
714
+ const selected = group.options.filter((option) => option.selected);
715
+ const byCount = [...group.options].sort(
716
+ (a, b) => (b.count ?? 0) - (a.count ?? 0)
717
+ );
718
+
719
+ if (query !== "") {
720
+ // One band while the box holds something: the chosen values first (they
721
+ // are the ones with an off-switch to reach), then the evidence order.
722
+ const hits = [
723
+ ...selected.filter(hit),
724
+ ...byCount.filter((option) => !option.selected && hit(option)),
725
+ ];
726
+ return {
727
+ total: hits.length,
728
+ groups: [
729
+ {
730
+ key: "all",
731
+ label: t(SEARCH_I18N_KEYS.facetsDictionaryAllValues),
732
+ options: hits.slice(0, page).map((o) => pickerRow(o, notCounted)),
733
+ },
734
+ ] as readonly PickerGroup[],
735
+ };
736
+ }
737
+
738
+ // The band never drops a chosen value, however cold it is: its cap grows
739
+ // to hold them rather than pushing one of them into the alphabet.
740
+ const cap = Math.max(FACET_VISIBLE_OPTIONS, selected.length);
741
+ const band: FacetOption[] = [];
742
+ const banded = new Set<string>();
743
+ for (const option of [...selected, ...byCount]) {
744
+ if (banded.has(option.value) || band.length >= cap) continue;
745
+ band.push(option);
746
+ banded.add(option.value);
747
+ }
748
+ const rest = group.options
749
+ .filter((option) => !banded.has(option.value))
750
+ .sort((a, b) => a.label.localeCompare(b.label));
751
+ return {
752
+ total: band.length + Math.min(rest.length, page),
753
+ groups: [
754
+ {
755
+ key: "band",
756
+ label: t(SEARCH_I18N_KEYS.facetsDictionaryRecommended),
757
+ options: band.map((o) => pickerRow(o, notCounted)),
758
+ },
759
+ {
760
+ key: "all",
761
+ label: t(SEARCH_I18N_KEYS.facetsDictionaryAllValues),
762
+ options: rest.slice(0, page).map((o) => pickerRow(o, notCounted)),
763
+ },
764
+ ] as readonly PickerGroup[],
765
+ };
766
+ }, [group, query, page, t]);
767
+
768
+ // How many rows exist behind the current page — what "there is more" means.
769
+ const available = group.options.length;
770
+
771
+ const text =
772
+ chosen.length > 0
773
+ ? chosen.map((option) => option.label).join(", ")
774
+ : t(SEARCH_I18N_KEYS.facetsDictionaryAny);
775
+
776
+ return (
777
+ <>
778
+ <button
779
+ type="button"
780
+ aria-haspopup="dialog"
781
+ aria-expanded={open}
782
+ aria-label={group.label}
783
+ style={DICTIONARY_FIELD}
784
+ data-testid={`facet-dictionary-trigger-${group.slug}`}
785
+ data-chosen={chosen.length}
786
+ data-analytics="none"
787
+ data-analytics-reason="opening a filter group is a read, not a flow step"
788
+ onClick={() => {
789
+ setOpen(true);
790
+ }}
791
+ >
792
+ <span style={DICTIONARY_FIELD_TEXT}>{text}</span>
793
+ {chosen.length > 0 && (
794
+ <Typography.Text
795
+ type="secondary"
796
+ data-testid={`facet-dictionary-trigger-count-${group.slug}`}
797
+ >
798
+ {chosen.length}
799
+ </Typography.Text>
800
+ )}
801
+ <ChevronGlyph open={open} />
802
+ </button>
803
+ <SkinPickerSheet
804
+ mode="multi"
805
+ open={open}
806
+ onClose={() => {
807
+ setOpen(false);
808
+ setNeedle("");
809
+ setPage(FACET_SHEET_PAGE);
810
+ }}
811
+ title={group.label}
812
+ testId={`facet-dictionary-sheet-${group.slug}`}
813
+ doneLabel={t(SEARCH_I18N_KEYS.facetsDictionaryDone)}
814
+ searchPlaceholder={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
815
+ emptyLabel={t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
816
+ // The caller owns the filtering — the sheet's own local filter matches
817
+ // on the label only, and this axis is searched across alphabets.
818
+ searchValue={needle}
819
+ onSearchChange={(next) => {
820
+ setNeedle(next);
821
+ setPage(FACET_SHEET_PAGE);
822
+ }}
823
+ groups={groups}
824
+ // Everything handed over is drawn: the paging above is this
825
+ // component's, so the sheet's own row cap must not fold it again.
826
+ maxRows={Math.max(1, total)}
827
+ onEndReached={() => {
828
+ setPage((current) =>
829
+ current >= available ? current : current + FACET_SHEET_PAGE
830
+ );
831
+ }}
832
+ values={group.selected}
833
+ onChange={(values) => {
834
+ props.onSetValues(group.slug, values);
835
+ }}
836
+ />
837
+ </>
838
+ );
839
+ }
840
+
841
+ /**
842
+ * The three faces of one dictionary, chosen by mode.
843
+ *
844
+ * `"sheet"` without a bulk setter falls back to the FIELD rather than to the
845
+ * inline wall: a trigger that opens a list is the shape both surfaces are
846
+ * moving to, and the wall is the thing the pass named.
847
+ */
848
+ function DictionaryControl(props: {
849
+ readonly group: FacetGroup;
850
+ readonly mode: "field" | "inline" | "sheet" | undefined;
851
+ readonly onToggle: (slug: string, value: string) => void;
852
+ readonly onSetValues?: (slug: string, values: readonly string[]) => void;
853
+ readonly visible: number;
854
+ }): ReactElement {
855
+ const { onSetValues } = props;
856
+ if (props.mode === "sheet" && onSetValues !== undefined) {
857
+ return <DictionarySheet group={props.group} onSetValues={onSetValues} />;
858
+ }
859
+ if (props.mode === "field" || props.mode === "sheet") {
860
+ return (
861
+ <DictionaryField
862
+ group={props.group}
863
+ onToggle={props.onToggle}
864
+ visible={props.visible}
865
+ />
866
+ );
867
+ }
868
+ return (
869
+ <DictionaryBody
870
+ group={props.group}
871
+ onToggle={props.onToggle}
872
+ visible={props.visible}
873
+ />
874
+ );
875
+ }
876
+
635
877
  export interface FacetGroupControlProps {
636
878
  readonly group: FacetGroup;
637
879
  readonly onToggle: (slug: string, value: string) => void;
@@ -652,13 +894,31 @@ export interface FacetGroupControlProps {
652
894
  * value only — the person owns the state after the first click. */
653
895
  readonly defaultOpen?: boolean;
654
896
  /**
655
- * How a `"dictionary"` group is drawn. `"field"` is the desktop shape — a
656
- * select-style field reading its chosen values or "Any", which opens the
657
- * searchable list; `"inline"` (the default) keeps the list open, the shape
658
- * a phone sheet wants because the sheet is already the disclosure.
659
- * Meaningless for the other three shapes.
897
+ * How a `"dictionary"` group is drawn. Meaningless for the other three
898
+ * shapes.
899
+ *
900
+ * - `"field"` the desktop shape: a select-style field reading its chosen
901
+ * values or "Any", which opens the searchable list under it;
902
+ * - `"sheet"` — the PHONE shape: a trigger row reading the same sentence,
903
+ * opening a nested picker sheet with a search box, a recommended band
904
+ * and the rest. The same control the composer's vocabulary picker is, so
905
+ * one dictionary is one gesture on both halves of the product. Needs
906
+ * {@link FacetGroupControlProps.onSetValues} — the sheet commits a whole
907
+ * draft at once, and a per-value toggle cannot apply one; without it the
908
+ * group falls back to `"field"`;
909
+ * - `"inline"` (the default) keeps the list open — a group that is already
910
+ * the only thing on its surface, such as a per-chip sheet.
660
911
  */
661
- readonly dictionaryMode?: "field" | "inline";
912
+ readonly dictionaryMode?: "field" | "inline" | "sheet";
913
+ /**
914
+ * Write a slug's chosen values in ONE go — `useFacetPanel`'s `setValues`.
915
+ *
916
+ * `onToggle` reads the current state to flip one value, so N toggles in one
917
+ * tick collapse into the last one; a sheet that commits a draft of several
918
+ * ticks needs the bulk write. Optional so no existing host changes, and
919
+ * only `dictionaryMode: "sheet"` reads it.
920
+ */
921
+ readonly onSetValues?: (slug: string, values: readonly string[]) => void;
662
922
  }
663
923
 
664
924
  export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
@@ -739,21 +999,17 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
739
999
  {/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
740
1000
  checkboxes are still a hundred stops for a screen reader, and the
741
1001
  measured rail held 118 of them. */}
742
- {open &&
743
- shape === "dictionary" &&
744
- (props.dictionaryMode === "field" ? (
745
- <DictionaryField
746
- group={group}
747
- onToggle={props.onToggle}
748
- visible={limit ?? FACET_VISIBLE_OPTIONS}
749
- />
750
- ) : (
751
- <DictionaryBody
752
- group={group}
753
- onToggle={props.onToggle}
754
- visible={limit ?? FACET_VISIBLE_OPTIONS}
755
- />
756
- ))}
1002
+ {open && shape === "dictionary" && (
1003
+ <DictionaryControl
1004
+ group={group}
1005
+ mode={props.dictionaryMode}
1006
+ onToggle={props.onToggle}
1007
+ {...(props.onSetValues !== undefined
1008
+ ? { onSetValues: props.onSetValues }
1009
+ : {})}
1010
+ visible={limit ?? FACET_VISIBLE_OPTIONS}
1011
+ />
1012
+ )}
757
1013
 
758
1014
  {open && shape !== "dictionary" && (
759
1015
  <>
@@ -315,10 +315,12 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
315
315
  /**
316
316
  * How a DICTIONARY group is drawn. `"field"` is the desktop shape — a
317
317
  * select-style field reading its chosen values or "Any", which opens the
318
- * searchable list; `"inline"` (the default) keeps the list open, which is
319
- * the phone sheet's shape because a sheet is already a disclosure.
318
+ * searchable list under it; `"sheet"` is the phone's the same trigger
319
+ * row, opening a nested picker sheet with a search box, a recommended band
320
+ * and the rest, which is the control the composer's vocabulary picker
321
+ * already is; `"inline"` (the default) keeps the list open.
320
322
  */
321
- readonly dictionaryMode?: "field" | "inline";
323
+ readonly dictionaryMode?: "field" | "inline" | "sheet";
322
324
  }
323
325
 
324
326
  /**
@@ -828,6 +830,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
828
830
  key={needle === "" ? group.slug : `${group.slug}:match`}
829
831
  group={group}
830
832
  onToggle={bag.toggle}
833
+ onSetValues={bag.setValues}
831
834
  collapsible
832
835
  defaultOpen={
833
836
  needle !== "" ||
@@ -30,9 +30,18 @@
30
30
  * show a different, larger number, so the caption would be a lie one click
31
31
  * later. Each entry therefore writes the `category` parameter of the search
32
32
  * already on screen, keeping the query: press "Cars 12" and twelve results
33
- * follow. That is a state change, so each entry is a real `<button>` and not
34
- * an anchor.
33
+ * follow.
35
34
  *
35
+ * Without {@link OtherCategoriesLineProps.categoryHref} that state change is
36
+ * ALL an entry does, so it is a plain `<button>` with no `href` — no address
37
+ * to hover, no "open in a new tab", nothing a crawler can follow. `categoryHref`
38
+ * turns the entry into a real `<a href>` without giving up the in-app
39
+ * narrowing: a plain click still rewrites the query in place (a full
40
+ * navigation would answer a different question than the one the count was
41
+ * counted for), while a modified click — the browser's own "open in a new
42
+ * tab/window" — is left alone and follows the address like any other link.
43
+ *
44
+
36
45
  * ## Two rows on a phone, at most
37
46
  *
38
47
  * The cap is halved on the sheet surface ({@link OTHER_CATEGORIES_PHONE_LIMIT}),
@@ -70,6 +79,10 @@ export const OTHER_CATEGORIES_SLOT_MIN_HEIGHT = 24;
70
79
  * drops the row rather than printing a number at a person. */
71
80
  export type OtherCategoryNamer = (category: string) => string | undefined;
72
81
 
82
+ /** Resolves an id path to a real, navigable address. Returning `undefined`
83
+ * leaves the row's in-app narrowing as the only way to press it. */
84
+ export type OtherCategoryHrefResolver = (category: string) => string | undefined;
85
+
73
86
  export interface OtherCategoriesLineProps {
74
87
  /** How many entries before the fold (default {@link OTHER_CATEGORIES_LIMIT}). */
75
88
  readonly limit?: number;
@@ -86,6 +99,24 @@ export interface OtherCategoriesLineProps {
86
99
  * slug — and drops the rest, because "163 · 149" is not a sentence.
87
100
  */
88
101
  readonly categoryName?: OtherCategoryNamer;
102
+ /**
103
+ * A real address for a category id path, when the host has one — a
104
+ * category page's own URL, most usefully.
105
+ *
106
+ * Without it every entry is a `<button>` with no `href`: it narrows the
107
+ * search on click, and nothing else — no "open in a new tab", no address
108
+ * to hover, nothing a crawler can follow. With it the entry becomes a real
109
+ * `<a href>` (a middle-click, a ctrl/cmd-click, "open in new tab" all work
110
+ * as they do for any link), while a plain click still narrows THIS search
111
+ * in place rather than leaving it — the whole reason the count beside a
112
+ * name is trustworthy is that it is a count for the query on screen, and a
113
+ * full navigation to the host's address would be answering a different
114
+ * question than the one the click asked.
115
+ *
116
+ * A row this returns nothing for keeps the in-app-only behaviour; the row
117
+ * is dropped only when it has no NAME, exactly as without this prop.
118
+ */
119
+ readonly categoryHref?: OtherCategoryHrefResolver;
89
120
  /** Skip the read entirely — mirrors `<SearchResultsPane enabled>`. */
90
121
  readonly enabled?: boolean;
91
122
  }
@@ -130,6 +161,7 @@ export function otherCategoriesCss(): string {
130
161
  interface Entry {
131
162
  readonly row: OtherCategoryRow;
132
163
  readonly name: string;
164
+ readonly href?: string;
133
165
  }
134
166
 
135
167
  export function OtherCategoriesLine(
@@ -152,7 +184,9 @@ export function OtherCategoriesLine(
152
184
  for (const row of bag.rows) {
153
185
  const name =
154
186
  props.categoryName?.(row.category) ?? row.name ?? otherCategoryLeaf(row.category);
155
- if (name !== undefined) entries.push({ row, name });
187
+ if (name === undefined) continue;
188
+ const href = props.categoryHref?.(row.category);
189
+ entries.push(href !== undefined ? { row, name, href } : { row, name });
156
190
  }
157
191
 
158
192
  const shown = expanded ? entries : entries.slice(0, limit);
@@ -201,12 +235,26 @@ export function OtherCategoriesLine(
201
235
  style={ENTRY}
202
236
  data-testid="search-other-category"
203
237
  data-category={entry.row.category}
238
+ {...(entry.href !== undefined ? { href: entry.href } : {})}
204
239
  data-analytics="none"
205
240
  data-analytics-reason="narrowing a search is a read, not a flow step"
206
241
  aria-label={t(SEARCH_I18N_KEYS.otherCategoriesNarrow, {
207
242
  name: entry.name,
208
243
  })}
209
- onClick={() => {
244
+ onClick={(event) => {
245
+ // A real `href` still narrows THIS search in place on a plain
246
+ // click — a full navigation would answer a different query
247
+ // than the one the count beside the name was counted for.
248
+ // Anything asking for a new tab/window (a modified click) is
249
+ // left to the browser, which is what makes the address real
250
+ // rather than decorative.
251
+ if (
252
+ entry.href !== undefined &&
253
+ (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
254
+ ) {
255
+ return;
256
+ }
257
+ event.preventDefault();
210
258
  setCategory(entry.row.category);
211
259
  }}
212
260
  >