@stapel/search-react 0.3.1 → 0.7.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 +180 -0
- package/MODULE.md +43 -4
- package/README.md +1 -1
- package/dist/api/generated/schema.d.ts +5 -1
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/DegradationNotice.d.ts +34 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +28 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +119 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- 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 +58 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +155 -6
- 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 +54 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +45 -25
- 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/index.d.ts +33 -16
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +26 -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/SearchResults.d.ts +20 -3
- package/dist/headless/SearchResults.d.ts.map +1 -1
- package/dist/headless/SearchResults.js +3 -1
- package/dist/headless/SearchResults.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 +32 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +46 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +86 -6
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +38 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- 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/degradations.d.ts +41 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +41 -0
- package/dist/state/degradations.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 -5
- package/manifest.json +245 -22
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +5 -1
- package/src/default/DegradationNotice.tsx +84 -14
- package/src/default/FacetPanelPane.tsx +343 -64
- 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 +356 -41
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +147 -76
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/index.ts +42 -16
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/SearchResults.tsx +32 -4
- 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 +39 -4
- package/src/i18n/keys.ts +97 -6
- package/src/i18n/ru.ts +45 -4
- package/src/index.ts +30 -3
- package/src/model/queries.ts +51 -1
- package/src/state/degradations.ts +60 -0
- 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>
|
package/src/default/index.ts
CHANGED
|
@@ -6,48 +6,74 @@
|
|
|
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
|
|
|
34
46
|
export { FacetPanelPane } from "./FacetPanelPane.js";
|
|
35
|
-
export type {
|
|
47
|
+
export type {
|
|
48
|
+
FacetPanelPaneProps,
|
|
49
|
+
CategoryFilterSlotProps,
|
|
50
|
+
GeoFilterSlotProps,
|
|
51
|
+
} from "./FacetPanelPane.js";
|
|
36
52
|
|
|
37
|
-
export { RankingDisclosurePane } from "./RankingDisclosurePane.js";
|
|
53
|
+
export { RankingDisclosurePane, RANKING_MAX_WIDTH } from "./RankingDisclosurePane.js";
|
|
38
54
|
export type { RankingDisclosurePaneProps } from "./RankingDisclosurePane.js";
|
|
39
55
|
|
|
56
|
+
// ── controls, exported so a host can compose its own layout ─────────────────
|
|
57
|
+
export { SearchBox } from "./SearchBox.js";
|
|
58
|
+
export type { SearchBoxProps } from "./SearchBox.js";
|
|
59
|
+
export { SortSelect, SORT_SELECT_MIN_WIDTH } from "./SortSelect.js";
|
|
60
|
+
export type { SortSelectProps } from "./SortSelect.js";
|
|
61
|
+
export { PageSizeSelect, SEARCH_PAGE_SIZES } from "./PageSizeSelect.js";
|
|
62
|
+
export type { PageSizeSelectProps } from "./PageSizeSelect.js";
|
|
63
|
+
export { LanguageSelect } from "./LanguageSelect.js";
|
|
64
|
+
export type { LanguageSelectProps } from "./LanguageSelect.js";
|
|
65
|
+
|
|
40
66
|
// ── parts, exported so a host can compose or wrap one ───────────────────────
|
|
41
67
|
export { SearchResultCard, GENERIC_CARD_FIELDS } from "./SearchResultCard.js";
|
|
42
68
|
export type { SearchCardProps, SearchCardRenderer } from "./SearchResultCard.js";
|
|
43
69
|
export { DegradationNotice } from "./DegradationNotice.js";
|
|
44
|
-
export type {
|
|
70
|
+
export type {
|
|
71
|
+
DegradationNoticeProps,
|
|
72
|
+
DegradationNoticeVariant,
|
|
73
|
+
} from "./DegradationNotice.js";
|
|
45
74
|
export { UrlIssueNotice } from "./UrlIssueNotice.js";
|
|
46
|
-
export {
|
|
47
|
-
export type {
|
|
48
|
-
export { ErrorAlert } from "./ErrorAlert.js";
|
|
75
|
+
export { RangeFilterRow } from "./RangeFilterRow.js";
|
|
76
|
+
export type { RangeFilterRowProps } from "./RangeFilterRow.js";
|
|
49
77
|
|
|
50
78
|
// ── theming ─────────────────────────────────────────────────────────────────
|
|
51
|
-
export { SearchSkinTheme } from "./theme.js";
|
|
52
|
-
export type { SearchSkinThemeProps } from "./theme.js";
|
|
53
79
|
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
|
}
|
|
@@ -15,15 +15,36 @@ import type {
|
|
|
15
15
|
} from "../api/types.js";
|
|
16
16
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
17
17
|
import { useSearchQuery } from "../model/queries.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
countIsEstimate,
|
|
20
|
+
countKind,
|
|
21
|
+
parseDegradations,
|
|
22
|
+
} from "../state/degradations.js";
|
|
23
|
+
import type { SearchCountKind } from "../state/degradations.js";
|
|
19
24
|
import { useSearchState } from "./SearchStateProvider.js";
|
|
20
25
|
|
|
21
26
|
/** What the envelope says about this page, beside the rows. */
|
|
22
27
|
export interface SearchPageInfo {
|
|
23
|
-
/**
|
|
24
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Total matches, or `null` when the engine cannot say — which is a real
|
|
30
|
+
* state and NOT zero. Read it together with {@link SearchPageInfo.countKind}:
|
|
31
|
+
* a `count` under `"at_least"` is a floor, not a total.
|
|
32
|
+
*/
|
|
33
|
+
readonly count: number | null;
|
|
25
34
|
readonly exactTotal: boolean;
|
|
26
|
-
/**
|
|
35
|
+
/** The server's own word: `count` is a floor ("at least N"), not a total. */
|
|
36
|
+
readonly countIsLowerBound: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The one value a skin needs to choose a sentence: `"exact"` → "N",
|
|
39
|
+
* `"at_least"` → "N+", `"unknown"` → no count at all.
|
|
40
|
+
*/
|
|
41
|
+
readonly countKind: SearchCountKind;
|
|
42
|
+
/**
|
|
43
|
+
* `true` when `count` must not be rendered as a plain number.
|
|
44
|
+
*
|
|
45
|
+
* @deprecated Equivalent to `countKind !== "exact"` and blind to
|
|
46
|
+
* `count: null`. Use {@link SearchPageInfo.countKind}.
|
|
47
|
+
*/
|
|
27
48
|
readonly countIsEstimate: boolean;
|
|
28
49
|
readonly hasNext: boolean;
|
|
29
50
|
readonly hasPrev: boolean;
|
|
@@ -102,6 +123,13 @@ export function SearchResults(props: {
|
|
|
102
123
|
: {
|
|
103
124
|
count: data.count,
|
|
104
125
|
exactTotal: data.exact_total,
|
|
126
|
+
countIsLowerBound: data.count_is_lower_bound === true,
|
|
127
|
+
countKind: countKind(
|
|
128
|
+
data.count,
|
|
129
|
+
data.count_is_lower_bound,
|
|
130
|
+
data.exact_total,
|
|
131
|
+
degradations
|
|
132
|
+
),
|
|
105
133
|
countIsEstimate: countIsEstimate(data.exact_total, degradations),
|
|
106
134
|
hasNext: data.has_next,
|
|
107
135
|
hasPrev: data.has_prev,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query box, headless — the state machine behind `<SearchBox>`.
|
|
3
|
+
*
|
|
4
|
+
* ── The one legitimate second copy of the state ────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* This pair's rule is that the URL is the state and no component keeps a copy.
|
|
7
|
+
* A text input is the single exception the rule has to make: what a person has
|
|
8
|
+
* TYPED is not yet what they have SEARCHED for, and committing every keystroke
|
|
9
|
+
* to the URL would make the address bar (and the request, and the query cache)
|
|
10
|
+
* chase the keyboard. So the draft lives here, for as long as it takes to stop
|
|
11
|
+
* typing, and the URL is still the only place a SEARCH is recorded.
|
|
12
|
+
*
|
|
13
|
+
* Two properties keep that honest:
|
|
14
|
+
*
|
|
15
|
+
* 1. **The URL wins whenever it moves on its own.** Back, a shared link
|
|
16
|
+
* opening, a host calling `setText` elsewhere — the draft is reset to the
|
|
17
|
+
* committed `q`. The box can never show a word the results are not about.
|
|
18
|
+
* 2. **Committing REPLACES the history entry** (`setText` already does), so a
|
|
19
|
+
* ten-letter word is one entry and Back still removes the last FILTER
|
|
20
|
+
* rather than the last letter.
|
|
21
|
+
*
|
|
22
|
+
* The suggestion list is asked for the DEBOUNCED prefix, not the draft — one
|
|
23
|
+
* request per pause, not per keystroke, on an endpoint the backend throttles.
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
26
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
27
|
+
import type { LoadState } from "@stapel/core";
|
|
28
|
+
import { useSearchState } from "./SearchStateProvider.js";
|
|
29
|
+
import { useSuggest } from "../model/queries.js";
|
|
30
|
+
import {
|
|
31
|
+
SEARCH_BOX_DEBOUNCE_MS,
|
|
32
|
+
SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
|
|
33
|
+
SEARCH_QUERY_MAX_CHARS,
|
|
34
|
+
SUGGEST_MIN_CHARS,
|
|
35
|
+
} from "../state/limits.js";
|
|
36
|
+
|
|
37
|
+
/** What a query box needs to read and move the search text. */
|
|
38
|
+
export interface SearchBoxBag {
|
|
39
|
+
/** What is in the input right now. */
|
|
40
|
+
readonly draft: string;
|
|
41
|
+
/** What the URL — and therefore the results — is about. */
|
|
42
|
+
readonly committed: string;
|
|
43
|
+
/** The draft has not reached the URL yet (a search is about to run). */
|
|
44
|
+
readonly pending: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Title prefixes for the typeahead — the READY answer only.
|
|
47
|
+
*
|
|
48
|
+
* A typeahead has no surface for "we could not fetch suggestions": the box
|
|
49
|
+
* must stay typeable and a person is mid-word. So a failed or in-flight
|
|
50
|
+
* suggest shows nothing, and the three answers stay distinguishable in
|
|
51
|
+
* {@link SearchBoxBag.suggestState} for a skin that wants to say more.
|
|
52
|
+
*/
|
|
53
|
+
readonly suggestions: readonly string[];
|
|
54
|
+
/** The suggest read as a state, unflattened. */
|
|
55
|
+
readonly suggestState: LoadState<readonly string[]>;
|
|
56
|
+
/** A suggestion request is in flight for a prefix nothing is shown for yet. */
|
|
57
|
+
readonly suggestLoading: boolean;
|
|
58
|
+
/** Characters a prefix needs before the index is asked. */
|
|
59
|
+
readonly minSuggestChars: number;
|
|
60
|
+
readonly maxLength: number;
|
|
61
|
+
|
|
62
|
+
/** Type. Commits after {@link SEARCH_BOX_DEBOUNCE_MS} of quiet. */
|
|
63
|
+
setDraft(next: string): void;
|
|
64
|
+
/** Commit now — Enter, the Search button, or picking a suggestion. */
|
|
65
|
+
submit(value?: string): void;
|
|
66
|
+
/** Empty the box AND the search (an empty `q` is a valid browse). */
|
|
67
|
+
clear(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface UseSearchBoxOptions {
|
|
71
|
+
readonly debounceMs?: number;
|
|
72
|
+
/** Ask the index for prefixes. Default `true`. */
|
|
73
|
+
readonly suggest?: boolean;
|
|
74
|
+
/** Quiet time before the index is asked (default
|
|
75
|
+
* {@link SEARCH_BOX_SUGGEST_DEBOUNCE_MS}). */
|
|
76
|
+
readonly suggestDebounceMs?: number;
|
|
77
|
+
/** How many suggestions to ask for (backend clamps to 25). Default 8. */
|
|
78
|
+
readonly suggestLimit?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One frozen empty list, so a render with no suggestions is a stable value. */
|
|
82
|
+
const NO_SUGGESTIONS: readonly string[] = [];
|
|
83
|
+
|
|
84
|
+
export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
85
|
+
const { state, setText } = useSearchState();
|
|
86
|
+
const committed = state.q;
|
|
87
|
+
const debounceMs = options.debounceMs ?? SEARCH_BOX_DEBOUNCE_MS;
|
|
88
|
+
|
|
89
|
+
const [draft, setDraftState] = useState(committed);
|
|
90
|
+
// What the URL said the last time we looked. When it changes underneath us
|
|
91
|
+
// — Back, a shared link, a host control — the draft follows it; when it
|
|
92
|
+
// changes BECAUSE we committed, `draft === committed` already and the reset
|
|
93
|
+
// is a no-op.
|
|
94
|
+
const lastCommitted = useRef(committed);
|
|
95
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
96
|
+
|
|
97
|
+
if (lastCommitted.current !== committed) {
|
|
98
|
+
lastCommitted.current = committed;
|
|
99
|
+
if (draft !== committed) setDraftState(committed);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cancel = useCallback((): void => {
|
|
103
|
+
if (timer.current !== null) {
|
|
104
|
+
clearTimeout(timer.current);
|
|
105
|
+
timer.current = null;
|
|
106
|
+
}
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
// A pending commit must not fire into an unmounted tree, and a box that is
|
|
110
|
+
// unmounted mid-word has not searched for that word.
|
|
111
|
+
useEffect(() => cancel, [cancel]);
|
|
112
|
+
|
|
113
|
+
const commit = useCallback(
|
|
114
|
+
(value: string): void => {
|
|
115
|
+
cancel();
|
|
116
|
+
if (value !== committed) setText(value);
|
|
117
|
+
},
|
|
118
|
+
[cancel, committed, setText]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const setDraft = useCallback(
|
|
122
|
+
(next: string): void => {
|
|
123
|
+
const clipped = next.slice(0, SEARCH_QUERY_MAX_CHARS);
|
|
124
|
+
setDraftState(clipped);
|
|
125
|
+
cancel();
|
|
126
|
+
timer.current = setTimeout(() => {
|
|
127
|
+
timer.current = null;
|
|
128
|
+
if (clipped !== committed) setText(clipped);
|
|
129
|
+
}, debounceMs);
|
|
130
|
+
},
|
|
131
|
+
[cancel, committed, debounceMs, setText]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const submit = useCallback(
|
|
135
|
+
(value?: string): void => {
|
|
136
|
+
const next = (value ?? draft).slice(0, SEARCH_QUERY_MAX_CHARS);
|
|
137
|
+
setDraftState(next);
|
|
138
|
+
commit(next);
|
|
139
|
+
},
|
|
140
|
+
[commit, draft]
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const clear = useCallback((): void => {
|
|
144
|
+
setDraftState("");
|
|
145
|
+
commit("");
|
|
146
|
+
}, [commit]);
|
|
147
|
+
|
|
148
|
+
// The index is asked about the SETTLED prefix, never the keystroke: one
|
|
149
|
+
// request per pause on an endpoint the backend throttles. `useSuggest` then
|
|
150
|
+
// refuses anything below `SUGGEST_MIN_CHARS` and keys the rest by prefix, so
|
|
151
|
+
// backspacing to a prefix already asked about costs nothing.
|
|
152
|
+
const suggestDebounceMs =
|
|
153
|
+
options.suggestDebounceMs ?? SEARCH_BOX_SUGGEST_DEBOUNCE_MS;
|
|
154
|
+
const [suggestPrefix, setSuggestPrefix] = useState(committed);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const handle = setTimeout(() => {
|
|
157
|
+
setSuggestPrefix(draft);
|
|
158
|
+
}, suggestDebounceMs);
|
|
159
|
+
return () => {
|
|
160
|
+
clearTimeout(handle);
|
|
161
|
+
};
|
|
162
|
+
}, [draft, suggestDebounceMs]);
|
|
163
|
+
|
|
164
|
+
const suggest = useSuggest({
|
|
165
|
+
type: state.type,
|
|
166
|
+
q: suggestPrefix,
|
|
167
|
+
...(options.suggestLimit !== undefined ? { limit: options.suggestLimit } : {}),
|
|
168
|
+
enabled: options.suggest !== false,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const suggestState = mapLoad(
|
|
172
|
+
loadStateFromQuery(suggest),
|
|
173
|
+
(data) => data.items as readonly string[]
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
draft,
|
|
178
|
+
committed,
|
|
179
|
+
pending: draft !== committed,
|
|
180
|
+
suggestions: suggestState.status === "ready" ? suggestState.data : NO_SUGGESTIONS,
|
|
181
|
+
suggestState,
|
|
182
|
+
suggestLoading: suggest.isLoading && suggest.fetchStatus === "fetching",
|
|
183
|
+
minSuggestChars: SUGGEST_MIN_CHARS,
|
|
184
|
+
maxLength: SEARCH_QUERY_MAX_CHARS,
|
|
185
|
+
setDraft,
|
|
186
|
+
submit,
|
|
187
|
+
clear,
|
|
188
|
+
};
|
|
189
|
+
}
|