@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.
- package/CHANGELOG.md +81 -0
- package/README.md +55 -3
- package/dist/default/FacetGroupControl.d.ts +33 -6
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +167 -4
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +5 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +1 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/OtherCategoriesLine.d.ts +21 -0
- package/dist/default/OtherCategoriesLine.d.ts.map +1 -1
- package/dist/default/OtherCategoriesLine.js +28 -6
- package/dist/default/OtherCategoriesLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts +14 -7
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -3
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +4 -1
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +2 -0
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +9 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +1 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +38 -1
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +70 -19
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +15 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +18 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +13 -4
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +9 -2
- package/nav-manifest.json +1 -1
- package/package.json +11 -11
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +278 -22
- package/src/default/FacetPanelPane.tsx +6 -3
- package/src/default/OtherCategoriesLine.tsx +52 -4
- package/src/default/SearchPage.tsx +25 -9
- package/src/default/SearchResultsPane.tsx +10 -1
- package/src/default/index.ts +2 -0
- package/src/headless/FacetPanel.tsx +10 -0
- package/src/headless/SearchStateProvider.tsx +99 -20
- package/src/i18n/es.ts +3 -0
- package/src/i18n/keys.ts +18 -0
- package/src/i18n/ru.ts +3 -0
- package/src/index.ts +4 -0
- 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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "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.
|
|
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": "
|
|
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": "
|
|
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.
|
|
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/
|
|
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"
|
|
@@ -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.
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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; `"
|
|
319
|
-
*
|
|
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.
|
|
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
|
|
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
|
>
|