@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.
Files changed (154) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1 -1
  3. package/dist/default/DegradationNotice.d.ts +7 -0
  4. package/dist/default/DegradationNotice.d.ts.map +1 -1
  5. package/dist/default/DegradationNotice.js +17 -6
  6. package/dist/default/DegradationNotice.js.map +1 -1
  7. package/dist/default/FacetGroupControl.d.ts +40 -0
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -0
  9. package/dist/default/FacetGroupControl.js +176 -0
  10. package/dist/default/FacetGroupControl.js.map +1 -0
  11. package/dist/default/FacetPanelPane.d.ts +54 -3
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +116 -22
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/FilterChips.d.ts +28 -0
  16. package/dist/default/FilterChips.d.ts.map +1 -0
  17. package/dist/default/FilterChips.js +204 -0
  18. package/dist/default/FilterChips.js.map +1 -0
  19. package/dist/default/LanguageSelect.d.ts +9 -0
  20. package/dist/default/LanguageSelect.d.ts.map +1 -0
  21. package/dist/default/LanguageSelect.js +72 -0
  22. package/dist/default/LanguageSelect.js.map +1 -0
  23. package/dist/default/PageSizeSelect.d.ts +29 -0
  24. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  25. package/dist/default/PageSizeSelect.js +25 -0
  26. package/dist/default/PageSizeSelect.js.map +1 -0
  27. package/dist/default/RangeFilterRow.d.ts +12 -0
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  29. package/dist/default/RangeFilterRow.js +75 -0
  30. package/dist/default/RangeFilterRow.js.map +1 -0
  31. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  32. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  33. package/dist/default/RankingDisclosurePane.js +15 -15
  34. package/dist/default/RankingDisclosurePane.js.map +1 -1
  35. package/dist/default/SearchBox.d.ts +13 -0
  36. package/dist/default/SearchBox.d.ts.map +1 -0
  37. package/dist/default/SearchBox.js +67 -0
  38. package/dist/default/SearchBox.js.map +1 -0
  39. package/dist/default/SearchPage.d.ts +94 -16
  40. package/dist/default/SearchPage.d.ts.map +1 -1
  41. package/dist/default/SearchPage.js +191 -8
  42. package/dist/default/SearchPage.js.map +1 -1
  43. package/dist/default/SearchResultCard.d.ts +19 -18
  44. package/dist/default/SearchResultCard.d.ts.map +1 -1
  45. package/dist/default/SearchResultCard.js +178 -5
  46. package/dist/default/SearchResultCard.js.map +1 -1
  47. package/dist/default/SearchResultsPane.d.ts +61 -3
  48. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  49. package/dist/default/SearchResultsPane.js +51 -21
  50. package/dist/default/SearchResultsPane.js.map +1 -1
  51. package/dist/default/SortSelect.d.ts +10 -2
  52. package/dist/default/SortSelect.d.ts.map +1 -1
  53. package/dist/default/SortSelect.js +27 -24
  54. package/dist/default/SortSelect.js.map +1 -1
  55. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  56. package/dist/default/UrlIssueNotice.js +19 -1
  57. package/dist/default/UrlIssueNotice.js.map +1 -1
  58. package/dist/default/ViewSwitch.d.ts +66 -0
  59. package/dist/default/ViewSwitch.d.ts.map +1 -0
  60. package/dist/default/ViewSwitch.js +49 -0
  61. package/dist/default/ViewSwitch.js.map +1 -0
  62. package/dist/default/index.d.ts +38 -15
  63. package/dist/default/index.d.ts.map +1 -1
  64. package/dist/default/index.js +29 -12
  65. package/dist/default/index.js.map +1 -1
  66. package/dist/default/sortLabels.d.ts +5 -0
  67. package/dist/default/sortLabels.d.ts.map +1 -0
  68. package/dist/default/sortLabels.js +32 -0
  69. package/dist/default/sortLabels.js.map +1 -0
  70. package/dist/headless/FacetPanel.d.ts +17 -0
  71. package/dist/headless/FacetPanel.d.ts.map +1 -1
  72. package/dist/headless/FacetPanel.js +23 -2
  73. package/dist/headless/FacetPanel.js.map +1 -1
  74. package/dist/headless/useAppliedCount.d.ts +8 -0
  75. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  76. package/dist/headless/useAppliedCount.js +30 -0
  77. package/dist/headless/useAppliedCount.js.map +1 -0
  78. package/dist/headless/useScorerNames.d.ts +4 -0
  79. package/dist/headless/useScorerNames.d.ts.map +1 -0
  80. package/dist/headless/useScorerNames.js +34 -0
  81. package/dist/headless/useScorerNames.js.map +1 -0
  82. package/dist/headless/useSearchBox.d.ts +44 -0
  83. package/dist/headless/useSearchBox.d.ts.map +1 -0
  84. package/dist/headless/useSearchBox.js +116 -0
  85. package/dist/headless/useSearchBox.js.map +1 -0
  86. package/dist/i18n/es.d.ts.map +1 -1
  87. package/dist/i18n/es.js +38 -4
  88. package/dist/i18n/es.js.map +1 -1
  89. package/dist/i18n/keys.d.ts +55 -0
  90. package/dist/i18n/keys.d.ts.map +1 -1
  91. package/dist/i18n/keys.js +101 -4
  92. package/dist/i18n/keys.js.map +1 -1
  93. package/dist/i18n/ru.d.ts.map +1 -1
  94. package/dist/i18n/ru.js +42 -4
  95. package/dist/i18n/ru.js.map +1 -1
  96. package/dist/index.d.ts +7 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +5 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/model/queries.d.ts +26 -2
  101. package/dist/model/queries.d.ts.map +1 -1
  102. package/dist/model/queries.js +38 -1
  103. package/dist/model/queries.js.map +1 -1
  104. package/dist/state/limits.d.ts +44 -0
  105. package/dist/state/limits.d.ts.map +1 -0
  106. package/dist/state/limits.js +44 -0
  107. package/dist/state/limits.js.map +1 -0
  108. package/dist/state/ranges.d.ts +52 -0
  109. package/dist/state/ranges.d.ts.map +1 -0
  110. package/dist/state/ranges.js +108 -0
  111. package/dist/state/ranges.js.map +1 -0
  112. package/llms.txt +16 -4
  113. package/manifest.json +268 -22
  114. package/nav-manifest.json +1 -1
  115. package/package.json +15 -10
  116. package/src/analytics/generated/events.json +1 -1
  117. package/src/default/DegradationNotice.tsx +34 -12
  118. package/src/default/FacetGroupControl.tsx +312 -0
  119. package/src/default/FacetPanelPane.tsx +320 -87
  120. package/src/default/FilterChips.tsx +415 -0
  121. package/src/default/LanguageSelect.tsx +93 -0
  122. package/src/default/PageSizeSelect.tsx +80 -0
  123. package/src/default/RangeFilterRow.tsx +148 -0
  124. package/src/default/RankingDisclosurePane.tsx +67 -35
  125. package/src/default/SearchBox.tsx +130 -0
  126. package/src/default/SearchPage.tsx +454 -44
  127. package/src/default/SearchResultCard.tsx +231 -24
  128. package/src/default/SearchResultsPane.tsx +158 -71
  129. package/src/default/SortSelect.tsx +50 -37
  130. package/src/default/UrlIssueNotice.tsx +21 -3
  131. package/src/default/ViewSwitch.tsx +147 -0
  132. package/src/default/index.ts +55 -15
  133. package/src/default/sortLabels.ts +34 -0
  134. package/src/headless/FacetPanel.tsx +30 -2
  135. package/src/headless/useAppliedCount.ts +41 -0
  136. package/src/headless/useScorerNames.ts +36 -0
  137. package/src/headless/useSearchBox.ts +189 -0
  138. package/src/i18n/es.ts +46 -4
  139. package/src/i18n/keys.ts +114 -4
  140. package/src/i18n/ru.ts +50 -4
  141. package/src/index.ts +22 -2
  142. package/src/model/queries.ts +51 -1
  143. package/src/state/limits.ts +50 -0
  144. package/src/state/ranges.ts +144 -0
  145. package/dist/default/ErrorAlert.d.ts +0 -9
  146. package/dist/default/ErrorAlert.d.ts.map +0 -1
  147. package/dist/default/ErrorAlert.js +0 -27
  148. package/dist/default/ErrorAlert.js.map +0 -1
  149. package/dist/default/theme.d.ts +0 -24
  150. package/dist/default/theme.d.ts.map +0 -1
  151. package/dist/default/theme.js +0 -39
  152. package/dist/default/theme.js.map +0 -1
  153. package/src/default/ErrorAlert.tsx +0 -46
  154. 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 { Empty, Flex, List, Spin, Tag, Typography } from "antd";
12
- import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
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 { ErrorAlert } from "./ErrorAlert.js";
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 describe = useDescribeFlowError();
41
+ const format = useFormat();
30
42
 
31
43
  return (
32
- <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
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={12} data-testid="search-ranking">
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
- {matchList(bag.state, {
44
- loading: () => (
45
- <Flex justify="center" style={{ padding: 16 }}>
46
- <Spin data-testid="ranking-loading" />
47
- </Flex>
48
- ),
49
- failed: (error) => (
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
- error={{
53
- ...describe(toFlowError(error)),
54
- message: t(SEARCH_I18N_KEYS.rankingLoadFailed),
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
- ready: (scorers) => (
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={2} style={{ width: "100%" }}>
71
- <Flex justify="space-between" align="center" gap={8}>
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
- <Tag>{scorer.weight}</Tag>
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 type="secondary" style={{ fontSize: 12 }}>
104
+ <Typography.Text
105
+ type="secondary"
106
+ style={{ fontSize: fontSize.xs.fontSize }}
107
+ >
81
108
  {t(SEARCH_I18N_KEYS.rankingAppliesTo)}
82
109
  {": "}
83
- {scorer.applies_to_sorts.join(", ")}
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={2}>
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
- </SearchSkinTheme>
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
+ }