@stapel/search-react 0.4.0 → 0.8.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 +191 -0
- package/README.md +1 -1
- package/dist/default/DegradationNotice.d.ts +7 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +17 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +40 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -0
- package/dist/default/FacetGroupControl.js +176 -0
- package/dist/default/FacetGroupControl.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +116 -22
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +28 -0
- package/dist/default/FilterChips.d.ts.map +1 -0
- package/dist/default/FilterChips.js +204 -0
- package/dist/default/FilterChips.js.map +1 -0
- package/dist/default/LanguageSelect.d.ts +9 -0
- package/dist/default/LanguageSelect.d.ts.map +1 -0
- package/dist/default/LanguageSelect.js +72 -0
- package/dist/default/LanguageSelect.js.map +1 -0
- package/dist/default/PageSizeSelect.d.ts +29 -0
- package/dist/default/PageSizeSelect.d.ts.map +1 -0
- package/dist/default/PageSizeSelect.js +25 -0
- package/dist/default/PageSizeSelect.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts +12 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -0
- package/dist/default/RangeFilterRow.js +75 -0
- package/dist/default/RangeFilterRow.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +5 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
- package/dist/default/RankingDisclosurePane.js +15 -15
- package/dist/default/RankingDisclosurePane.js.map +1 -1
- package/dist/default/SearchBox.d.ts +13 -0
- package/dist/default/SearchBox.d.ts.map +1 -0
- package/dist/default/SearchBox.js +67 -0
- package/dist/default/SearchBox.js.map +1 -0
- package/dist/default/SearchPage.d.ts +94 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +191 -8
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts +19 -18
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +178 -5
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +61 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +51 -21
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -2
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +27 -24
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/UrlIssueNotice.d.ts.map +1 -1
- package/dist/default/UrlIssueNotice.js +19 -1
- package/dist/default/UrlIssueNotice.js.map +1 -1
- package/dist/default/ViewSwitch.d.ts +66 -0
- package/dist/default/ViewSwitch.d.ts.map +1 -0
- package/dist/default/ViewSwitch.js +49 -0
- package/dist/default/ViewSwitch.js.map +1 -0
- package/dist/default/index.d.ts +38 -15
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +29 -12
- package/dist/default/index.js.map +1 -1
- package/dist/default/sortLabels.d.ts +5 -0
- package/dist/default/sortLabels.d.ts.map +1 -0
- package/dist/default/sortLabels.js +32 -0
- package/dist/default/sortLabels.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +17 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +23 -2
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useAppliedCount.d.ts +8 -0
- package/dist/headless/useAppliedCount.d.ts.map +1 -0
- package/dist/headless/useAppliedCount.js +30 -0
- package/dist/headless/useAppliedCount.js.map +1 -0
- package/dist/headless/useScorerNames.d.ts +4 -0
- package/dist/headless/useScorerNames.d.ts.map +1 -0
- package/dist/headless/useScorerNames.js +34 -0
- package/dist/headless/useScorerNames.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +44 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -0
- package/dist/headless/useSearchBox.js +116 -0
- package/dist/headless/useSearchBox.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +38 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +55 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +101 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +42 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +26 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +38 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/limits.d.ts +44 -0
- package/dist/state/limits.d.ts.map +1 -0
- package/dist/state/limits.js +44 -0
- package/dist/state/limits.js.map +1 -0
- package/dist/state/ranges.d.ts +52 -0
- package/dist/state/ranges.d.ts.map +1 -0
- package/dist/state/ranges.js +108 -0
- package/dist/state/ranges.js.map +1 -0
- package/llms.txt +16 -4
- package/manifest.json +268 -22
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/default/DegradationNotice.tsx +34 -12
- package/src/default/FacetGroupControl.tsx +312 -0
- package/src/default/FacetPanelPane.tsx +320 -87
- package/src/default/FilterChips.tsx +415 -0
- package/src/default/LanguageSelect.tsx +93 -0
- package/src/default/PageSizeSelect.tsx +80 -0
- package/src/default/RangeFilterRow.tsx +148 -0
- package/src/default/RankingDisclosurePane.tsx +67 -35
- package/src/default/SearchBox.tsx +130 -0
- package/src/default/SearchPage.tsx +454 -44
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +158 -71
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/ViewSwitch.tsx +147 -0
- package/src/default/index.ts +55 -15
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/useAppliedCount.ts +41 -0
- package/src/headless/useScorerNames.ts +36 -0
- package/src/headless/useSearchBox.ts +189 -0
- package/src/i18n/es.ts +46 -4
- package/src/i18n/keys.ts +114 -4
- package/src/i18n/ru.ts +50 -4
- package/src/index.ts +22 -2
- package/src/model/queries.ts +51 -1
- package/src/state/limits.ts +50 -0
- package/src/state/ranges.ts +144 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -24
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -39
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -66
|
@@ -10,24 +10,29 @@
|
|
|
10
10
|
*
|
|
11
11
|
* `sort=distance` needs a centre — the server answers
|
|
12
12
|
* `error.400.search_sort_needs_center` without one. The option is therefore
|
|
13
|
-
* DISABLED
|
|
14
|
-
*
|
|
13
|
+
* DISABLED when no geo centre is set, and the REASON is rendered beside the
|
|
14
|
+
* control through `GatedControl`, not in a `title=` a phone can never surface.
|
|
15
|
+
* That is the whole defect this file used to carry: the one sort a person
|
|
16
|
+
* would most want on a phone was greyed out with its explanation in a hover.
|
|
15
17
|
*/
|
|
16
18
|
import type { ReactElement } from "react";
|
|
17
19
|
import { Flex, Select, Typography } from "antd";
|
|
18
|
-
import { useT } from "@stapel/core";
|
|
20
|
+
import { actionAvailable, actionBlocked, useT } from "@stapel/core";
|
|
21
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
22
|
+
import { GatedControl } from "@stapel/tokens-antd/skin";
|
|
23
|
+
import { spacing } from "@stapel/tokens";
|
|
19
24
|
import { SEARCH_SORTS } from "../api/types.js";
|
|
20
25
|
import { useAppliedSort } from "../headless/useAppliedSort.js";
|
|
21
26
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
22
27
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
28
|
+
import { sortLabelKey } from "./sortLabels.js";
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
};
|
|
30
|
+
/**
|
|
31
|
+
* The select's floor width. Off the spacing scale on purpose and named for it:
|
|
32
|
+
* it is the width of the longest shipped sort label ("Price: low to high") at
|
|
33
|
+
* the default type step, so the control does not resize as the choice changes.
|
|
34
|
+
*/
|
|
35
|
+
export const SORT_SELECT_MIN_WIDTH = 200;
|
|
31
36
|
|
|
32
37
|
export interface SortSelectProps {
|
|
33
38
|
/** The sort the SERVER applied, shown when the URL names none. Omitted, it
|
|
@@ -54,33 +59,41 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
54
59
|
? [...SEARCH_SORTS, active]
|
|
55
60
|
: SEARCH_SORTS;
|
|
56
61
|
|
|
62
|
+
// The gate is about ONE option, not the whole control — so the binding's
|
|
63
|
+
// `aria-describedby` is spread onto the select (a screen reader hears the
|
|
64
|
+
// reason with the control) and its `disabled` deliberately is not: the other
|
|
65
|
+
// four sorts work perfectly well without a location.
|
|
66
|
+
const distance: ActionAvailability = hasCentre
|
|
67
|
+
? actionAvailable()
|
|
68
|
+
: actionBlocked("error.400.search_sort_needs_center");
|
|
69
|
+
|
|
57
70
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
value
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
</
|
|
71
|
+
<GatedControl gate={distance} testId="search-sort-gate">
|
|
72
|
+
{(bind) => (
|
|
73
|
+
<Flex gap={spacing[2]} align="center">
|
|
74
|
+
<Typography.Text type="secondary" aria-hidden="true">
|
|
75
|
+
{t(SEARCH_I18N_KEYS.sortLabel)}
|
|
76
|
+
</Typography.Text>
|
|
77
|
+
<Select<string>
|
|
78
|
+
data-testid="search-sort"
|
|
79
|
+
aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
|
|
80
|
+
aria-describedby={bind["aria-describedby"]}
|
|
81
|
+
style={{ minWidth: SORT_SELECT_MIN_WIDTH }}
|
|
82
|
+
value={active ?? null}
|
|
83
|
+
onChange={(next) => {
|
|
84
|
+
setSort(next);
|
|
85
|
+
}}
|
|
86
|
+
options={values.map((value) => {
|
|
87
|
+
const key = sortLabelKey(value);
|
|
88
|
+
return {
|
|
89
|
+
value,
|
|
90
|
+
label: key !== undefined ? t(key) : value,
|
|
91
|
+
disabled: value === "distance" && !hasCentre,
|
|
92
|
+
};
|
|
93
|
+
})}
|
|
94
|
+
/>
|
|
95
|
+
</Flex>
|
|
96
|
+
)}
|
|
97
|
+
</GatedControl>
|
|
85
98
|
);
|
|
86
99
|
}
|
|
@@ -9,10 +9,28 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ReactElement } from "react";
|
|
11
11
|
import { Alert } from "antd";
|
|
12
|
+
import { spacing } from "@stapel/tokens";
|
|
12
13
|
import { useT } from "@stapel/core";
|
|
14
|
+
import { FILTER_PREFIX, RANGE_PREFIX } from "../state/urlState.js";
|
|
13
15
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
14
16
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The parameter as a person can recognise it.
|
|
20
|
+
*
|
|
21
|
+
* The codec reports the WIRE name, which for a facet or a range carries the
|
|
22
|
+
* prefix that tells the backend which family it belongs to (`r.price`,
|
|
23
|
+
* `f.brand`). That prefix is a protocol detail: the sentence is read by
|
|
24
|
+
* somebody who followed a link, and "price" is the part of `r.price` they
|
|
25
|
+
* have any chance of recognising.
|
|
26
|
+
*/
|
|
27
|
+
function readableParam(param: string): string {
|
|
28
|
+
for (const prefix of [RANGE_PREFIX, FILTER_PREFIX]) {
|
|
29
|
+
if (param.startsWith(prefix)) return param.slice(prefix.length);
|
|
30
|
+
}
|
|
31
|
+
return param;
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
export function UrlIssueNotice(): ReactElement | null {
|
|
17
35
|
const t = useT();
|
|
18
36
|
const { issues } = useSearchState();
|
|
@@ -22,12 +40,12 @@ export function UrlIssueNotice(): ReactElement | null {
|
|
|
22
40
|
type="warning"
|
|
23
41
|
showIcon
|
|
24
42
|
data-testid="search-url-issues"
|
|
25
|
-
|
|
43
|
+
title={t(SEARCH_I18N_KEYS.urlIssuesTitle)}
|
|
26
44
|
description={
|
|
27
|
-
<ul style={{ margin: 0, paddingInlineStart:
|
|
45
|
+
<ul style={{ margin: 0, paddingInlineStart: spacing[5] }}>
|
|
28
46
|
{issues.map((issue) => (
|
|
29
47
|
<li key={`${issue.param}:${issue.code}`}>
|
|
30
|
-
{t(issue.messageKey, { param: issue.param })}
|
|
48
|
+
{t(issue.messageKey, { param: readableParam(issue.param) })}
|
|
31
49
|
</li>
|
|
32
50
|
))}
|
|
33
51
|
</ul>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ViewSwitch>` — how the results are ARRANGED: a list, a grid, or something
|
|
3
|
+
* the deployment brought with it.
|
|
4
|
+
*
|
|
5
|
+
* ## Why two of the three views ship and the third is a slot
|
|
6
|
+
*
|
|
7
|
+
* A list and a grid are the SAME data in two arrangements: one column of wide
|
|
8
|
+
* rows, or as many card columns as fit. That is a layout decision and a search
|
|
9
|
+
* pair can take it. A MAP is not — it needs a tile source, a projection, a
|
|
10
|
+
* marker layer and a geocoder, all of which live in `geo-react` and none of
|
|
11
|
+
* which a search package may depend on. So the third view arrives as a
|
|
12
|
+
* {@link SearchView} the host declares, with its own `render`, and this
|
|
13
|
+
* component treats it exactly like the two it ships. A pair that hardcoded a
|
|
14
|
+
* "map" button and then had nothing to draw would be offering a control that
|
|
15
|
+
* cannot work — the defect class the fleet calls a silent slot.
|
|
16
|
+
*
|
|
17
|
+
* ## The view is not URL state, and that is deliberate
|
|
18
|
+
*
|
|
19
|
+
* `sort`, `page size`, every filter and the cursor are in the query string,
|
|
20
|
+
* because they change WHAT the answer is and a shared link has to carry them.
|
|
21
|
+
* The view changes only how the same answer is drawn. Putting it in the URL
|
|
22
|
+
* would make every arrangement flip a new history entry and would rewrite the
|
|
23
|
+
* meaning of a link somebody sent. The page holds it in component state and
|
|
24
|
+
* offers `defaultView`/`onViewChange` so a host that wants it remembered can
|
|
25
|
+
* persist it wherever it keeps preferences.
|
|
26
|
+
*/
|
|
27
|
+
import type { ReactElement, ReactNode } from "react";
|
|
28
|
+
import { Segmented } from "antd";
|
|
29
|
+
import { useT } from "@stapel/core";
|
|
30
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
31
|
+
import type { SearchResultsRenderer } from "./SearchResultsPane.js";
|
|
32
|
+
|
|
33
|
+
/** How the pane's own arrangements lay a page of results out. */
|
|
34
|
+
export type SearchResultsLayout = "grid" | "list";
|
|
35
|
+
|
|
36
|
+
/** One arrangement offered by the switch. */
|
|
37
|
+
export interface SearchView {
|
|
38
|
+
/** Stable id — what `defaultView`/`onViewChange` speak in. */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
/** i18n KEY for the view's name (never a literal: the switch is chrome). */
|
|
41
|
+
readonly labelKey: string;
|
|
42
|
+
/** The glyph beside the name. Omitted, the name stands alone. */
|
|
43
|
+
readonly icon?: ReactNode;
|
|
44
|
+
/**
|
|
45
|
+
* One of the pane's own arrangements. Ignored when {@link SearchView.render}
|
|
46
|
+
* is present, which replaces the arrangement entirely.
|
|
47
|
+
*/
|
|
48
|
+
readonly layout?: SearchResultsLayout;
|
|
49
|
+
/**
|
|
50
|
+
* The whole result surface, for a view the pair cannot draw — a map, a
|
|
51
|
+
* comparison table. It receives the loaded rows; the pane keeps its four
|
|
52
|
+
* load arms around it, so "nothing found" and "we could not run this search"
|
|
53
|
+
* stay the pane's sentences rather than the slot's problem.
|
|
54
|
+
*/
|
|
55
|
+
readonly render?: SearchResultsRenderer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Rows — one wide row per result, the arrangement a scan reads fastest. */
|
|
59
|
+
function ListGlyph(): ReactElement {
|
|
60
|
+
return (
|
|
61
|
+
<ViewGlyph>
|
|
62
|
+
<path d="M4 6.5h16M4 12h16M4 17.5h16" />
|
|
63
|
+
</ViewGlyph>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Cards — as many columns as fit. */
|
|
68
|
+
function GridGlyph(): ReactElement {
|
|
69
|
+
return (
|
|
70
|
+
<ViewGlyph>
|
|
71
|
+
<rect x="4" y="4" width="7" height="7" rx="1.5" />
|
|
72
|
+
<rect x="13" y="4" width="7" height="7" rx="1.5" />
|
|
73
|
+
<rect x="4" y="13" width="7" height="7" rx="1.5" />
|
|
74
|
+
<rect x="13" y="13" width="7" height="7" rx="1.5" />
|
|
75
|
+
</ViewGlyph>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ViewGlyph(props: { readonly children: ReactNode }): ReactElement {
|
|
80
|
+
return (
|
|
81
|
+
<svg
|
|
82
|
+
width="16"
|
|
83
|
+
height="16"
|
|
84
|
+
viewBox="0 0 24 24"
|
|
85
|
+
fill="none"
|
|
86
|
+
stroke="currentColor"
|
|
87
|
+
strokeWidth="1.75"
|
|
88
|
+
strokeLinecap="round"
|
|
89
|
+
role="img"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
{props.children}
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The two arrangements the pair itself can draw. */
|
|
98
|
+
export const SEARCH_BUILTIN_VIEWS: readonly SearchView[] = [
|
|
99
|
+
{
|
|
100
|
+
id: "list",
|
|
101
|
+
labelKey: SEARCH_I18N_KEYS.viewList,
|
|
102
|
+
icon: <ListGlyph />,
|
|
103
|
+
layout: "list",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "grid",
|
|
107
|
+
labelKey: SEARCH_I18N_KEYS.viewGrid,
|
|
108
|
+
icon: <GridGlyph />,
|
|
109
|
+
layout: "grid",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/** The view `id` currently in force, or the first offered one. */
|
|
114
|
+
export function resolveView(
|
|
115
|
+
views: readonly SearchView[],
|
|
116
|
+
id: string | undefined
|
|
117
|
+
): SearchView | undefined {
|
|
118
|
+
return views.find((view) => view.id === id) ?? views[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ViewSwitchProps {
|
|
122
|
+
readonly views: readonly SearchView[];
|
|
123
|
+
readonly value: string;
|
|
124
|
+
readonly onChange: (id: string) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The switch itself. Renders nothing for a single view: a control offering one
|
|
129
|
+
* choice is not a control, it is a label that can be clicked.
|
|
130
|
+
*/
|
|
131
|
+
export function ViewSwitch(props: ViewSwitchProps): ReactElement | null {
|
|
132
|
+
const t = useT();
|
|
133
|
+
if (props.views.length < 2) return null;
|
|
134
|
+
return (
|
|
135
|
+
<Segmented<string>
|
|
136
|
+
aria-label={t(SEARCH_I18N_KEYS.viewLabel)}
|
|
137
|
+
value={props.value}
|
|
138
|
+
data-testid="search-view-switch"
|
|
139
|
+
onChange={props.onChange}
|
|
140
|
+
options={props.views.map((view) => ({
|
|
141
|
+
value: view.id,
|
|
142
|
+
label: t(view.labelKey),
|
|
143
|
+
...(view.icon !== undefined ? { icon: view.icon } : {}),
|
|
144
|
+
}))}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -6,37 +6,80 @@
|
|
|
6
6
|
* never pays for this bundle (enforced by size-limit and the bundle-purity
|
|
7
7
|
* test).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Five override levers, none of which requires forking anything:
|
|
10
10
|
*
|
|
11
11
|
* 1. **`renderCard`** — the card slot. A storefront passes
|
|
12
12
|
* `<ListingCard>` from `@stapel/listings-react/default`; the generic card
|
|
13
13
|
* here is the documented default, not the intended end state (spec §3.7).
|
|
14
14
|
* `renderResults` is the level above it: the whole layout, for a container
|
|
15
15
|
* whose arrangement is not "cards in a grid".
|
|
16
|
-
* 2. **`categoryFeatures`** — the facet-label slot
|
|
17
|
-
* `{value: count}` and no
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* 2. **`categoryFeatures`** — the facet-label slot, and the source of which
|
|
17
|
+
* slugs get a numeric range row. The server sends `{value: count}` and no
|
|
18
|
+
* labels; the schema that names them lives in categories, and the
|
|
19
|
+
* container hands it in (spec §6.2 item 2).
|
|
20
|
+
* 3. **`renderCategoryFilter`** — the catalogue picker. `categories-react`
|
|
21
|
+
* owns walking the tree; this pair owns the `category` parameter.
|
|
22
|
+
* 4. **`renderGeoFilter`** — the location control. `geo-react` owns the map
|
|
23
|
+
* and the geocoder; this pair owns `lat`/`lon`/`radius_km`/`bbox`, and
|
|
24
|
+
* keeps the controls that WIDEN a location a link already carries.
|
|
25
|
+
* 5. **retheming through the §68 token JSON** — every surface wraps itself
|
|
26
|
+
* in the shared `SkinTheme`, so a host's regenerated `--stapel-*` custom
|
|
21
27
|
* properties reach this skin with zero code.
|
|
28
|
+
*
|
|
29
|
+
* The pair's own `theme.tsx` and `ErrorAlert.tsx` are GONE as of 0.6.0: both
|
|
30
|
+
* were copies of a decision now stated once in `@stapel/tokens-antd/skin`
|
|
31
|
+
* (`SkinTheme`, `ErrorAlert`). A host that imported `SearchSkinTheme` imports
|
|
32
|
+
* `SkinTheme` from the substrate instead — same props, one implementation, and
|
|
33
|
+
* a runtime `data-theme` flip actually repaints it.
|
|
22
34
|
*/
|
|
23
35
|
|
|
24
36
|
// ── surfaces ────────────────────────────────────────────────────────────────
|
|
25
37
|
export { SearchPage } from "./SearchPage.js";
|
|
26
|
-
export type { SearchPageProps } from "./SearchPage.js";
|
|
38
|
+
export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
|
|
27
39
|
|
|
28
|
-
export { SearchResultsPane } from "./SearchResultsPane.js";
|
|
40
|
+
export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
|
|
29
41
|
export type {
|
|
30
42
|
SearchResultsPaneProps,
|
|
31
43
|
SearchResultsRenderer,
|
|
32
44
|
} from "./SearchResultsPane.js";
|
|
33
45
|
|
|
46
|
+
export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
|
|
47
|
+
export type { FilterChipsProps } from "./FilterChips.js";
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
FacetGroupControl,
|
|
51
|
+
facetGroupShape,
|
|
52
|
+
facetOptionNodes,
|
|
53
|
+
FACET_VISIBLE_OPTIONS,
|
|
54
|
+
} from "./FacetGroupControl.js";
|
|
55
|
+
export type {
|
|
56
|
+
FacetGroupControlProps,
|
|
57
|
+
FacetGroupShape,
|
|
58
|
+
FacetOptionNode,
|
|
59
|
+
} from "./FacetGroupControl.js";
|
|
60
|
+
|
|
34
61
|
export { FacetPanelPane } from "./FacetPanelPane.js";
|
|
35
|
-
export type {
|
|
62
|
+
export type {
|
|
63
|
+
FacetPanelPaneProps,
|
|
64
|
+
CategoryFilterSlotProps,
|
|
65
|
+
GeoFilterSlotProps,
|
|
66
|
+
} from "./FacetPanelPane.js";
|
|
36
67
|
|
|
37
|
-
export { RankingDisclosurePane } from "./RankingDisclosurePane.js";
|
|
68
|
+
export { RankingDisclosurePane, RANKING_MAX_WIDTH } from "./RankingDisclosurePane.js";
|
|
38
69
|
export type { RankingDisclosurePaneProps } from "./RankingDisclosurePane.js";
|
|
39
70
|
|
|
71
|
+
// ── controls, exported so a host can compose its own layout ─────────────────
|
|
72
|
+
export { SearchBox } from "./SearchBox.js";
|
|
73
|
+
export type { SearchBoxProps } from "./SearchBox.js";
|
|
74
|
+
export { SortSelect, SORT_SELECT_MIN_WIDTH } from "./SortSelect.js";
|
|
75
|
+
export type { SortSelectProps } from "./SortSelect.js";
|
|
76
|
+
export { ViewSwitch, SEARCH_BUILTIN_VIEWS, resolveView } from "./ViewSwitch.js";
|
|
77
|
+
export type { ViewSwitchProps, SearchView, SearchResultsLayout } from "./ViewSwitch.js";
|
|
78
|
+
export { PageSizeSelect, SEARCH_PAGE_SIZES } from "./PageSizeSelect.js";
|
|
79
|
+
export type { PageSizeSelectProps } from "./PageSizeSelect.js";
|
|
80
|
+
export { LanguageSelect } from "./LanguageSelect.js";
|
|
81
|
+
export type { LanguageSelectProps } from "./LanguageSelect.js";
|
|
82
|
+
|
|
40
83
|
// ── parts, exported so a host can compose or wrap one ───────────────────────
|
|
41
84
|
export { SearchResultCard, GENERIC_CARD_FIELDS } from "./SearchResultCard.js";
|
|
42
85
|
export type { SearchCardProps, SearchCardRenderer } from "./SearchResultCard.js";
|
|
@@ -46,11 +89,8 @@ export type {
|
|
|
46
89
|
DegradationNoticeVariant,
|
|
47
90
|
} from "./DegradationNotice.js";
|
|
48
91
|
export { UrlIssueNotice } from "./UrlIssueNotice.js";
|
|
49
|
-
export {
|
|
50
|
-
export type {
|
|
51
|
-
export { ErrorAlert } from "./ErrorAlert.js";
|
|
92
|
+
export { RangeFilterRow } from "./RangeFilterRow.js";
|
|
93
|
+
export type { RangeFilterRowProps } from "./RangeFilterRow.js";
|
|
52
94
|
|
|
53
95
|
// ── theming ─────────────────────────────────────────────────────────────────
|
|
54
|
-
export { SearchSkinTheme } from "./theme.js";
|
|
55
|
-
export type { SearchSkinThemeProps } from "./theme.js";
|
|
56
96
|
export type { ThemeModeProp } from "./types.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sort SLUG as the skin names it, in one place.
|
|
3
|
+
*
|
|
4
|
+
* `relevance`, `price_asc`, `distance` are wire values: the URL carries them,
|
|
5
|
+
* the envelope reports them, and the ranking disclosure lists them per scorer.
|
|
6
|
+
* The sort SELECT translated them and the disclosure did not, so the same page
|
|
7
|
+
* offered "Most relevant" in the control and stamped "Applies to: relevance"
|
|
8
|
+
* under every parameter — the registry's word, in a statutory text, next to
|
|
9
|
+
* the human word for the same thing.
|
|
10
|
+
*
|
|
11
|
+
* A slug the pair does not ship is returned as it is. Deployments register
|
|
12
|
+
* their own scorers and sorts, and an unknown one is a real value that belongs
|
|
13
|
+
* on screen; only an invented label would be worse.
|
|
14
|
+
*/
|
|
15
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
16
|
+
|
|
17
|
+
const SORT_LABEL_KEY: Readonly<Record<string, string>> = {
|
|
18
|
+
relevance: SEARCH_I18N_KEYS.sortRelevance,
|
|
19
|
+
newest: SEARCH_I18N_KEYS.sortNewest,
|
|
20
|
+
price_asc: SEARCH_I18N_KEYS.sortPriceAsc,
|
|
21
|
+
price_desc: SEARCH_I18N_KEYS.sortPriceDesc,
|
|
22
|
+
distance: SEARCH_I18N_KEYS.sortDistance,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The i18n key for a shipped sort, or `undefined` for a deployment's own. */
|
|
26
|
+
export function sortLabelKey(sort: string): string | undefined {
|
|
27
|
+
return SORT_LABEL_KEY[sort];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The sort's name for a reader, falling back to the slug. */
|
|
31
|
+
export function sortLabel(t: (key: string) => string, sort: string): string {
|
|
32
|
+
const key = SORT_LABEL_KEY[sort];
|
|
33
|
+
return key === undefined ? sort : t(key);
|
|
34
|
+
}
|
|
@@ -72,6 +72,34 @@ export function FacetPanel(props: {
|
|
|
72
72
|
enabled?: boolean;
|
|
73
73
|
children: (bag: FacetPanelBag) => ReactNode;
|
|
74
74
|
}): ReactNode {
|
|
75
|
+
return props.children(
|
|
76
|
+
useFacetPanel({
|
|
77
|
+
...(props.categoryFeatures !== undefined
|
|
78
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
79
|
+
: {}),
|
|
80
|
+
...(props.locale !== undefined ? { locale: props.locale } : {}),
|
|
81
|
+
...(props.enabled !== undefined ? { enabled: props.enabled } : {}),
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The same bag, as a hook — for a caller that has to know what the panel WILL
|
|
88
|
+
* render before it renders it.
|
|
89
|
+
*
|
|
90
|
+
* The one real caller is `<SearchPage>`: a facet column is a quarter of a
|
|
91
|
+
* catalogue page, and on a deployment whose plan has no facets at all it was
|
|
92
|
+
* a quarter of every results page spent on an empty-state illustration
|
|
93
|
+
* repeating "no filters for this search". Whether to lay out that column is a
|
|
94
|
+
* LAYOUT decision, and layout is decided by the component that owns the grid
|
|
95
|
+
* — which therefore has to be able to ask. A render prop cannot answer a
|
|
96
|
+
* question asked one level up.
|
|
97
|
+
*/
|
|
98
|
+
export function useFacetPanel(props: {
|
|
99
|
+
categoryFeatures?: readonly FeatureDef[];
|
|
100
|
+
locale?: string;
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
} = {}): FacetPanelBag {
|
|
75
103
|
const { state: searchState, setFilter, setRange, clearAll, toggleFilter, activeFilters } =
|
|
76
104
|
useSearchState();
|
|
77
105
|
const t = useT();
|
|
@@ -96,7 +124,7 @@ export function FacetPanel(props: {
|
|
|
96
124
|
})
|
|
97
125
|
);
|
|
98
126
|
|
|
99
|
-
return
|
|
127
|
+
return {
|
|
100
128
|
state: groups,
|
|
101
129
|
approximate: meta.approximate,
|
|
102
130
|
skipped: meta.skipped,
|
|
@@ -109,5 +137,5 @@ export function FacetPanel(props: {
|
|
|
109
137
|
setFilter(slug, []);
|
|
110
138
|
},
|
|
111
139
|
clearAll,
|
|
112
|
-
}
|
|
140
|
+
};
|
|
113
141
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many results the page currently in cache holds, and whether that number
|
|
3
|
+
* may be spoken as a number.
|
|
4
|
+
*
|
|
5
|
+
* The same trick as {@link useAppliedSort}: `enabled: false` on the key
|
|
6
|
+
* `<SearchResults>` populates, so a control that wants to SAY the count
|
|
7
|
+
* subscribes to the answer already on screen instead of issuing a second
|
|
8
|
+
* search of its own. A filter sheet whose button reads "Show 25 results" is
|
|
9
|
+
* the difference between committing to a change and guessing at one, and it
|
|
10
|
+
* must not cost a request to know that.
|
|
11
|
+
*
|
|
12
|
+
* Not exported from the package entry: it is the shape `<SearchPage>` needs to
|
|
13
|
+
* label one button, and a public hook would be a second, thinner way to ask
|
|
14
|
+
* what `<SearchResults>` already answers in full.
|
|
15
|
+
*/
|
|
16
|
+
import { useSearchQuery } from "../model/queries.js";
|
|
17
|
+
import { countKind, parseDegradations } from "../state/degradations.js";
|
|
18
|
+
import type { SearchCountKind } from "../state/degradations.js";
|
|
19
|
+
import { useSearchState } from "./SearchStateProvider.js";
|
|
20
|
+
|
|
21
|
+
export interface AppliedCount {
|
|
22
|
+
/** `null` when the engine cannot say — never rendered as `0`. */
|
|
23
|
+
readonly count: number | null;
|
|
24
|
+
readonly kind: SearchCountKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useAppliedCount(): AppliedCount {
|
|
28
|
+
const { state } = useSearchState();
|
|
29
|
+
const query = useSearchQuery(state, { enabled: false });
|
|
30
|
+
const data = query.data;
|
|
31
|
+
if (data === undefined) return { count: null, kind: "unknown" };
|
|
32
|
+
return {
|
|
33
|
+
count: data.count,
|
|
34
|
+
kind: countKind(
|
|
35
|
+
data.count,
|
|
36
|
+
data.count_is_lower_bound,
|
|
37
|
+
data.exact_total,
|
|
38
|
+
parseDegradations(data.degraded)
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scorer SLUGS turned into the names the disclosure already gives them.
|
|
3
|
+
*
|
|
4
|
+
* `degraded[]` reports a scorer the engine could not evaluate as
|
|
5
|
+
* `"scorer:geo_decay"` — a registry identifier, because that is the only
|
|
6
|
+
* handle the query envelope has. Printed straight into a sentence it reads
|
|
7
|
+
* "The ranking parameter “geo_decay” was not applied", which is a developer's
|
|
8
|
+
* word in a shopper's banner (class C-DEVCOPY).
|
|
9
|
+
*
|
|
10
|
+
* The name is not this pair's to invent: the backend already publishes one per
|
|
11
|
+
* scorer on `GET /ranking` (`description_key`, a translation key). So the
|
|
12
|
+
* lookup goes there — through the SAME cache entry `<RankingDisclosure>`
|
|
13
|
+
* fills, with `enabled: false`, so a result page never issues a second request
|
|
14
|
+
* for a label. When the disclosure has not been read the map is empty and the
|
|
15
|
+
* banner falls back to the slug, which is what it said before and is at least
|
|
16
|
+
* traceable.
|
|
17
|
+
*/
|
|
18
|
+
import { useT } from "@stapel/core";
|
|
19
|
+
import { useRankingDisclosure } from "../model/queries.js";
|
|
20
|
+
import { useSearchState } from "./SearchStateProvider.js";
|
|
21
|
+
|
|
22
|
+
/** `(slug) => name`, or `undefined` when nothing in cache names that slug. */
|
|
23
|
+
export type ScorerNameLookup = (slug: string) => string | undefined;
|
|
24
|
+
|
|
25
|
+
export function useScorerNames(): ScorerNameLookup {
|
|
26
|
+
const t = useT();
|
|
27
|
+
const { state } = useSearchState();
|
|
28
|
+
const query = useRankingDisclosure(state.type, { enabled: false });
|
|
29
|
+
const scorers = query.data?.scorers;
|
|
30
|
+
return (slug: string) => {
|
|
31
|
+
const scorer = scorers?.find((candidate) => candidate.slug === slug);
|
|
32
|
+
if (scorer === undefined) return undefined;
|
|
33
|
+
const named = t(scorer.description_key);
|
|
34
|
+
return named.length > 0 && named !== scorer.description_key ? named : undefined;
|
|
35
|
+
};
|
|
36
|
+
}
|