@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One numeric range row — "Price: from ___ to ___".
|
|
3
|
+
*
|
|
4
|
+
* The pair could encode `r.price=100..500`, round-trip it through the URL and
|
|
5
|
+
* send it, and had no control that produced one. A marketplace without a price
|
|
6
|
+
* range filter is not a marketplace (audit S-2), and this is the row that fixes
|
|
7
|
+
* it; `state/ranges.ts` decides which rows exist.
|
|
8
|
+
*
|
|
9
|
+
* ── Why it is applied by a button and not by typing ───────────────────────
|
|
10
|
+
*
|
|
11
|
+
* A facet checkbox commits on click because a click is a finished thought. A
|
|
12
|
+
* range is TWO fields, and committing each keystroke would run a search for
|
|
13
|
+
* `1`, `10`, `100` on the way to `1000` — three wrong result pages, three
|
|
14
|
+
* history entries' worth of churn, and a facet panel that reshuffles under the
|
|
15
|
+
* hand still typing. So the row holds a draft and commits on Apply (or Enter),
|
|
16
|
+
* which is also what makes "from > to" refusable instead of merely empty.
|
|
17
|
+
*/
|
|
18
|
+
import { useRef, useState } from "react";
|
|
19
|
+
import type { ReactElement } from "react";
|
|
20
|
+
import { Button, Flex, InputNumber, Typography } from "antd";
|
|
21
|
+
import { actionAvailable, actionBlocked, useT } from "@stapel/core";
|
|
22
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
23
|
+
import { GatedButton } from "@stapel/tokens-antd/skin";
|
|
24
|
+
import { spacing } from "@stapel/tokens";
|
|
25
|
+
import type { SearchRange } from "../api/types.js";
|
|
26
|
+
import type { RangeGroup } from "../state/ranges.js";
|
|
27
|
+
import { isRangeUsable } from "../state/ranges.js";
|
|
28
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
29
|
+
|
|
30
|
+
/** Floor width of one bound field, so two of them fit a 390px phone row. */
|
|
31
|
+
export const RANGE_FIELD_MIN_WIDTH = 96;
|
|
32
|
+
|
|
33
|
+
export interface RangeFilterRowProps {
|
|
34
|
+
readonly group: RangeGroup;
|
|
35
|
+
/** `null` clears the slug's range entirely. */
|
|
36
|
+
readonly onApply: (slug: string, range: SearchRange | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toDraft(value: string | undefined): string {
|
|
40
|
+
return value ?? "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
44
|
+
const t = useT();
|
|
45
|
+
const { group } = props;
|
|
46
|
+
const [from, setFrom] = useState(toDraft(group.from));
|
|
47
|
+
const [to, setTo] = useState(toDraft(group.to));
|
|
48
|
+
// The URL is still the state: when it moves on its own — Back, a shared
|
|
49
|
+
// link, "clear all" — the draft follows it rather than keeping a number the
|
|
50
|
+
// results are no longer about.
|
|
51
|
+
const applied = useRef<string>(`${toDraft(group.from)}..${toDraft(group.to)}`);
|
|
52
|
+
const current = `${toDraft(group.from)}..${toDraft(group.to)}`;
|
|
53
|
+
if (applied.current !== current) {
|
|
54
|
+
applied.current = current;
|
|
55
|
+
if (from !== toDraft(group.from)) setFrom(toDraft(group.from));
|
|
56
|
+
if (to !== toDraft(group.to)) setTo(toDraft(group.to));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const draft: SearchRange = {
|
|
60
|
+
...(from !== "" ? { from } : {}),
|
|
61
|
+
...(to !== "" ? { to } : {}),
|
|
62
|
+
};
|
|
63
|
+
const usable = isRangeUsable(draft);
|
|
64
|
+
const empty = from === "" && to === "";
|
|
65
|
+
const apply: ActionAvailability = usable
|
|
66
|
+
? actionAvailable()
|
|
67
|
+
: actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
|
|
68
|
+
|
|
69
|
+
const commit = (): void => {
|
|
70
|
+
if (!usable) return;
|
|
71
|
+
props.onApply(group.slug, empty ? null : draft);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const unit = group.unit === undefined ? "" : ` ${group.unit}`;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Flex
|
|
78
|
+
vertical
|
|
79
|
+
gap={spacing[1]}
|
|
80
|
+
data-testid={`facet-range-${group.slug}`}
|
|
81
|
+
data-active={group.active ? "true" : "false"}
|
|
82
|
+
>
|
|
83
|
+
<Typography.Text strong>{group.label}</Typography.Text>
|
|
84
|
+
<Flex gap={spacing[2]} align="center" wrap>
|
|
85
|
+
<InputNumber
|
|
86
|
+
value={from === "" ? null : Number(from)}
|
|
87
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsRangeFrom)}
|
|
88
|
+
aria-label={`${t(SEARCH_I18N_KEYS.facetsRangeFromAria, {
|
|
89
|
+
feature: group.label,
|
|
90
|
+
})}${unit}`}
|
|
91
|
+
data-testid={`facet-range-${group.slug}-from`}
|
|
92
|
+
style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
|
|
93
|
+
{...(group.min !== undefined ? { min: group.min } : {})}
|
|
94
|
+
{...(group.max !== undefined ? { max: group.max } : {})}
|
|
95
|
+
{...(group.step !== undefined ? { step: group.step } : {})}
|
|
96
|
+
onChange={(value) => {
|
|
97
|
+
setFrom(value === null || value === undefined ? "" : String(value));
|
|
98
|
+
}}
|
|
99
|
+
onPressEnter={commit}
|
|
100
|
+
/>
|
|
101
|
+
<InputNumber
|
|
102
|
+
value={to === "" ? null : Number(to)}
|
|
103
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsRangeTo)}
|
|
104
|
+
aria-label={`${t(SEARCH_I18N_KEYS.facetsRangeToAria, {
|
|
105
|
+
feature: group.label,
|
|
106
|
+
})}${unit}`}
|
|
107
|
+
data-testid={`facet-range-${group.slug}-to`}
|
|
108
|
+
style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
|
|
109
|
+
{...(group.min !== undefined ? { min: group.min } : {})}
|
|
110
|
+
{...(group.max !== undefined ? { max: group.max } : {})}
|
|
111
|
+
{...(group.step !== undefined ? { step: group.step } : {})}
|
|
112
|
+
onChange={(value) => {
|
|
113
|
+
setTo(value === null || value === undefined ? "" : String(value));
|
|
114
|
+
}}
|
|
115
|
+
onPressEnter={commit}
|
|
116
|
+
/>
|
|
117
|
+
{/* Primary when there is something to apply, secondary when there is
|
|
118
|
+
not. It used to be the other way round — filled over two empty
|
|
119
|
+
fields, ghosted the moment the person had typed the numbers the
|
|
120
|
+
button exists to submit (class C-NOPRIMARY). And no `size="small"`:
|
|
121
|
+
a filter row that a phone cannot hit is not a filter row, and the
|
|
122
|
+
shared `SkinTheme` only raises the DEFAULT control height to 44. */}
|
|
123
|
+
<GatedButton
|
|
124
|
+
gate={apply}
|
|
125
|
+
type={usable && !empty ? "primary" : "default"}
|
|
126
|
+
testId={`facet-range-${group.slug}-apply`}
|
|
127
|
+
data-analytics="none"
|
|
128
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
129
|
+
onClick={commit}
|
|
130
|
+
>
|
|
131
|
+
{t(SEARCH_I18N_KEYS.facetsRangeApply)}
|
|
132
|
+
</GatedButton>
|
|
133
|
+
{group.active && (
|
|
134
|
+
<Button
|
|
135
|
+
data-testid={`facet-range-${group.slug}-clear`}
|
|
136
|
+
data-analytics="none"
|
|
137
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
138
|
+
onClick={() => {
|
|
139
|
+
props.onApply(group.slug, null);
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{t(SEARCH_I18N_KEYS.facetsRangeClear)}
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
</Flex>
|
|
146
|
+
</Flex>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -8,13 +8,19 @@
|
|
|
8
8
|
* shipping.
|
|
9
9
|
*/
|
|
10
10
|
import type { ReactElement } from "react";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { Flex, List, Tag, Typography } from "antd";
|
|
12
|
+
import { useFormat, useT } from "@stapel/core";
|
|
13
|
+
import {
|
|
14
|
+
EmptyState,
|
|
15
|
+
ErrorAlert,
|
|
16
|
+
LoadList,
|
|
17
|
+
SkinTheme,
|
|
18
|
+
} from "@stapel/tokens-antd/skin";
|
|
19
|
+
import { fontSize, spacing } from "@stapel/tokens";
|
|
13
20
|
import type { Scorer } from "../api/types.js";
|
|
14
21
|
import { RankingDisclosure } from "../headless/RankingDisclosure.js";
|
|
15
22
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
16
|
-
import {
|
|
17
|
-
import { SearchSkinTheme } from "./theme.js";
|
|
23
|
+
import { sortLabel } from "./sortLabels.js";
|
|
18
24
|
import type { ThemeModeProp } from "./types.js";
|
|
19
25
|
|
|
20
26
|
export interface RankingDisclosurePaneProps extends ThemeModeProp {
|
|
@@ -22,17 +28,27 @@ export interface RankingDisclosurePaneProps extends ThemeModeProp {
|
|
|
22
28
|
readonly type?: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The widest a disclosure column may grow. A statutory text is a READING
|
|
33
|
+
* surface: past this it stops being a paragraph and becomes a banner.
|
|
34
|
+
*/
|
|
35
|
+
export const RANKING_MAX_WIDTH = 720;
|
|
36
|
+
|
|
25
37
|
export function RankingDisclosurePane(
|
|
26
38
|
props: RankingDisclosurePaneProps
|
|
27
39
|
): ReactElement {
|
|
28
40
|
const t = useT();
|
|
29
|
-
const
|
|
41
|
+
const format = useFormat();
|
|
30
42
|
|
|
31
43
|
return (
|
|
32
|
-
<
|
|
44
|
+
<SkinTheme
|
|
45
|
+
surface="base"
|
|
46
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
47
|
+
style={{ width: "100%", maxWidth: RANKING_MAX_WIDTH }}
|
|
48
|
+
>
|
|
33
49
|
<RankingDisclosure {...(props.type !== undefined ? { type: props.type } : {})}>
|
|
34
50
|
{(bag) => (
|
|
35
|
-
<Flex vertical gap={
|
|
51
|
+
<Flex vertical gap={spacing[3]} data-testid="search-ranking">
|
|
36
52
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
37
53
|
{t(SEARCH_I18N_KEYS.rankingTitle)}
|
|
38
54
|
</Typography.Title>
|
|
@@ -40,47 +56,63 @@ export function RankingDisclosurePane(
|
|
|
40
56
|
{t(SEARCH_I18N_KEYS.rankingIntro)}
|
|
41
57
|
</Typography.Paragraph>
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
<LoadList
|
|
60
|
+
state={bag.state}
|
|
61
|
+
testId="ranking"
|
|
62
|
+
skeletonRows={4}
|
|
63
|
+
onRetry={bag.refetch}
|
|
64
|
+
empty={
|
|
65
|
+
<EmptyState
|
|
66
|
+
compact
|
|
67
|
+
title={t(SEARCH_I18N_KEYS.rankingEmpty)}
|
|
68
|
+
testId="ranking-empty"
|
|
69
|
+
/>
|
|
70
|
+
}
|
|
71
|
+
failed={(error) => (
|
|
50
72
|
<ErrorAlert
|
|
51
73
|
testId="ranking-failed"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}}
|
|
56
|
-
/>
|
|
57
|
-
),
|
|
58
|
-
empty: () => (
|
|
59
|
-
<Empty
|
|
60
|
-
data-testid="ranking-empty"
|
|
61
|
-
description={t(SEARCH_I18N_KEYS.rankingEmpty)}
|
|
74
|
+
thrown={error}
|
|
75
|
+
message={t(SEARCH_I18N_KEYS.rankingLoadFailed)}
|
|
76
|
+
onRetry={bag.refetch}
|
|
62
77
|
/>
|
|
63
|
-
)
|
|
64
|
-
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{(scorers) => (
|
|
65
81
|
<List<Scorer>
|
|
66
82
|
data-testid="ranking-list"
|
|
67
83
|
dataSource={[...scorers]}
|
|
68
84
|
renderItem={(scorer) => (
|
|
69
85
|
<List.Item key={scorer.slug} data-scorer={scorer.slug}>
|
|
70
|
-
<Flex vertical gap={
|
|
71
|
-
<Flex justify="space-between" align="center" gap={
|
|
86
|
+
<Flex vertical gap={spacing[1]} style={{ width: "100%" }}>
|
|
87
|
+
<Flex justify="space-between" align="center" gap={spacing[2]}>
|
|
72
88
|
<Typography.Text strong>
|
|
73
89
|
{t(scorer.description_key)}
|
|
74
90
|
</Typography.Text>
|
|
75
|
-
|
|
91
|
+
{/* A bare "0.3" is a number with no unit in a
|
|
92
|
+
statutory text. The tag says what the number IS;
|
|
93
|
+
the disclosure's intro already says weights are
|
|
94
|
+
relative to each other. */}
|
|
95
|
+
<Tag>
|
|
96
|
+
{t(SEARCH_I18N_KEYS.rankingWeight)}
|
|
97
|
+
{" "}
|
|
98
|
+
{format.number(scorer.weight) ?? scorer.weight}
|
|
99
|
+
</Tag>
|
|
76
100
|
</Flex>
|
|
77
101
|
<Typography.Text type="secondary">
|
|
78
102
|
{scorer.description}
|
|
79
103
|
</Typography.Text>
|
|
80
|
-
<Typography.Text
|
|
104
|
+
<Typography.Text
|
|
105
|
+
type="secondary"
|
|
106
|
+
style={{ fontSize: fontSize.xs.fontSize }}
|
|
107
|
+
>
|
|
81
108
|
{t(SEARCH_I18N_KEYS.rankingAppliesTo)}
|
|
82
109
|
{": "}
|
|
83
|
-
{
|
|
110
|
+
{/* The sorts a reader can actually choose, by the
|
|
111
|
+
names the sort control gives them — not the
|
|
112
|
+
registry's slugs. */}
|
|
113
|
+
{scorer.applies_to_sorts
|
|
114
|
+
.map((sort) => sortLabel(t, sort))
|
|
115
|
+
.join(", ")}
|
|
84
116
|
</Typography.Text>
|
|
85
117
|
{!scorer.active && (
|
|
86
118
|
<Typography.Text type="warning" data-testid="ranking-inactive">
|
|
@@ -93,11 +125,11 @@ export function RankingDisclosurePane(
|
|
|
93
125
|
</List.Item>
|
|
94
126
|
)}
|
|
95
127
|
/>
|
|
96
|
-
)
|
|
97
|
-
|
|
128
|
+
)}
|
|
129
|
+
</LoadList>
|
|
98
130
|
|
|
99
131
|
{bag.notes.length > 0 && (
|
|
100
|
-
<Flex vertical gap={
|
|
132
|
+
<Flex vertical gap={spacing[1]}>
|
|
101
133
|
<Typography.Text strong>
|
|
102
134
|
{t(SEARCH_I18N_KEYS.rankingNotes)}
|
|
103
135
|
</Typography.Text>
|
|
@@ -111,6 +143,6 @@ export function RankingDisclosurePane(
|
|
|
111
143
|
</Flex>
|
|
112
144
|
)}
|
|
113
145
|
</RankingDisclosure>
|
|
114
|
-
</
|
|
146
|
+
</SkinTheme>
|
|
115
147
|
);
|
|
116
148
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<SearchBox>` — the control this pair shipped a search page without.
|
|
3
|
+
*
|
|
4
|
+
* `setText` existed from the first release and had ZERO callers in the whole
|
|
5
|
+
* repository: the URL codec could carry `q`, the state machine could set it,
|
|
6
|
+
* the request sent it — and nothing on any screen could type one. A results
|
|
7
|
+
* page reachable only by editing the address bar is not a search feature, so
|
|
8
|
+
* this is the blocker of the pair's audit (S-1) and the reason the page is now
|
|
9
|
+
* a screen rather than a viewer.
|
|
10
|
+
*
|
|
11
|
+
* ── What it does, and what it refuses to do ───────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* - **Types into a draft, searches after a pause.** `useSearchBox` owns the
|
|
14
|
+
* debounce and the URL round-trip; this file is the antd shape of it.
|
|
15
|
+
* - **Suggests from the INDEX.** `GET /suggest` returns title prefixes out of
|
|
16
|
+
* the catalogue — never a query log, because stapel-search keeps none — so
|
|
17
|
+
* every suggestion is a search that has results. The endpoint was typed and
|
|
18
|
+
* unreachable for three releases (S-8).
|
|
19
|
+
* - **Never grows a "no results" dropdown.** With nothing to suggest the menu
|
|
20
|
+
* stays shut: an empty popover under a half-typed word says "there is
|
|
21
|
+
* nothing" about a search that has not run.
|
|
22
|
+
* - **Caps the input at the server's own limit** (200 chars), so
|
|
23
|
+
* `error.400.search_query_too_long` is a refusal this control cannot cause.
|
|
24
|
+
*
|
|
25
|
+
* Exported from `/default` so a container can mount it in the header —
|
|
26
|
+
* `nav/manifest.ts` calls `/s` "a navigation TARGET reached from the header's
|
|
27
|
+
* search box", and this is that box. It needs the same `<SearchStateProvider>`
|
|
28
|
+
* as everything else; a header outside one renders `<SearchPage>`'s copy.
|
|
29
|
+
*/
|
|
30
|
+
import { useState } from "react";
|
|
31
|
+
import type { ReactElement } from "react";
|
|
32
|
+
import { AutoComplete, Button, Flex, Input } from "antd";
|
|
33
|
+
import { useT } from "@stapel/core";
|
|
34
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
35
|
+
import { spacing } from "@stapel/tokens";
|
|
36
|
+
import { useSearchBox } from "../headless/useSearchBox.js";
|
|
37
|
+
import type { UseSearchBoxOptions } from "../headless/useSearchBox.js";
|
|
38
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
39
|
+
import type { ThemeModeProp } from "./types.js";
|
|
40
|
+
|
|
41
|
+
export interface SearchBoxProps extends ThemeModeProp, UseSearchBoxOptions {
|
|
42
|
+
/** Override the placeholder — a category page says what it searches. */
|
|
43
|
+
readonly placeholder?: string;
|
|
44
|
+
/** Render the submit button beside the field. Default `true`: on a phone
|
|
45
|
+
* keyboard "search" is not always offered, and a visible button is. */
|
|
46
|
+
readonly submitButton?: boolean;
|
|
47
|
+
readonly autoFocus?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function SearchBox(props: SearchBoxProps): ReactElement {
|
|
51
|
+
const t = useT();
|
|
52
|
+
const {
|
|
53
|
+
mode,
|
|
54
|
+
placeholder,
|
|
55
|
+
submitButton,
|
|
56
|
+
autoFocus,
|
|
57
|
+
...boxOptions
|
|
58
|
+
} = props;
|
|
59
|
+
const box = useSearchBox(boxOptions);
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
|
|
62
|
+
const options = box.suggestions.map((value) => ({ value }));
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<SkinTheme
|
|
66
|
+
surface="bare"
|
|
67
|
+
{...(mode !== undefined ? { mode } : {})}
|
|
68
|
+
data-testid="search-box"
|
|
69
|
+
>
|
|
70
|
+
<Flex gap={spacing[2]} align="center" wrap={false}>
|
|
71
|
+
<AutoComplete
|
|
72
|
+
value={box.draft}
|
|
73
|
+
options={options}
|
|
74
|
+
// Shut whenever there is nothing to say — see the header. The
|
|
75
|
+
// open state is the skin's, not antd's: the prop that reports the
|
|
76
|
+
// menu opening was renamed between antd 5 and 6 and this package
|
|
77
|
+
// supports both, so nothing here asks antd when to open.
|
|
78
|
+
open={open && options.length > 0}
|
|
79
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
80
|
+
onSelect={(value: string) => {
|
|
81
|
+
setOpen(false);
|
|
82
|
+
box.submit(value);
|
|
83
|
+
}}
|
|
84
|
+
onChange={(value: string) => {
|
|
85
|
+
setOpen(true);
|
|
86
|
+
box.setDraft(value);
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<Input
|
|
90
|
+
allowClear={{
|
|
91
|
+
clearIcon: (
|
|
92
|
+
<span aria-label={t(SEARCH_I18N_KEYS.boxClear)} role="img">
|
|
93
|
+
{"×"}
|
|
94
|
+
</span>
|
|
95
|
+
),
|
|
96
|
+
}}
|
|
97
|
+
autoFocus={autoFocus === true}
|
|
98
|
+
maxLength={box.maxLength}
|
|
99
|
+
aria-label={t(SEARCH_I18N_KEYS.boxLabel)}
|
|
100
|
+
placeholder={placeholder ?? t(SEARCH_I18N_KEYS.boxPlaceholder)}
|
|
101
|
+
data-testid="search-box-input"
|
|
102
|
+
data-analytics="none"
|
|
103
|
+
data-analytics-reason="typing a query is a read, not a flow step"
|
|
104
|
+
onPressEnter={() => {
|
|
105
|
+
setOpen(false);
|
|
106
|
+
box.submit();
|
|
107
|
+
}}
|
|
108
|
+
onBlur={() => {
|
|
109
|
+
setOpen(false);
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
</AutoComplete>
|
|
113
|
+
{submitButton !== false && (
|
|
114
|
+
<Button
|
|
115
|
+
type="primary"
|
|
116
|
+
data-testid="search-box-submit"
|
|
117
|
+
data-analytics="none"
|
|
118
|
+
data-analytics-reason="running a search is a read, not a flow step"
|
|
119
|
+
onClick={() => {
|
|
120
|
+
setOpen(false);
|
|
121
|
+
box.submit();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{t(SEARCH_I18N_KEYS.boxSubmit)}
|
|
125
|
+
</Button>
|
|
126
|
+
)}
|
|
127
|
+
</Flex>
|
|
128
|
+
</SkinTheme>
|
|
129
|
+
);
|
|
130
|
+
}
|