@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.
Files changed (153) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/MODULE.md +43 -4
  3. package/README.md +1 -1
  4. package/dist/api/generated/schema.d.ts +5 -1
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/DegradationNotice.d.ts +34 -0
  7. package/dist/default/DegradationNotice.d.ts.map +1 -1
  8. package/dist/default/DegradationNotice.js +28 -6
  9. package/dist/default/DegradationNotice.js.map +1 -1
  10. package/dist/default/FacetPanelPane.d.ts +54 -3
  11. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  12. package/dist/default/FacetPanelPane.js +119 -19
  13. package/dist/default/FacetPanelPane.js.map +1 -1
  14. package/dist/default/LanguageSelect.d.ts +9 -0
  15. package/dist/default/LanguageSelect.d.ts.map +1 -0
  16. package/dist/default/LanguageSelect.js +72 -0
  17. package/dist/default/LanguageSelect.js.map +1 -0
  18. package/dist/default/PageSizeSelect.d.ts +29 -0
  19. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  20. package/dist/default/PageSizeSelect.js +25 -0
  21. package/dist/default/PageSizeSelect.js.map +1 -0
  22. package/dist/default/RangeFilterRow.d.ts +12 -0
  23. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  24. package/dist/default/RangeFilterRow.js +75 -0
  25. package/dist/default/RangeFilterRow.js.map +1 -0
  26. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  27. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  28. package/dist/default/RankingDisclosurePane.js +15 -15
  29. package/dist/default/RankingDisclosurePane.js.map +1 -1
  30. package/dist/default/SearchBox.d.ts +13 -0
  31. package/dist/default/SearchBox.d.ts.map +1 -0
  32. package/dist/default/SearchBox.js +67 -0
  33. package/dist/default/SearchBox.js.map +1 -0
  34. package/dist/default/SearchPage.d.ts +58 -16
  35. package/dist/default/SearchPage.d.ts.map +1 -1
  36. package/dist/default/SearchPage.js +155 -6
  37. package/dist/default/SearchPage.js.map +1 -1
  38. package/dist/default/SearchResultCard.d.ts +19 -18
  39. package/dist/default/SearchResultCard.d.ts.map +1 -1
  40. package/dist/default/SearchResultCard.js +178 -5
  41. package/dist/default/SearchResultCard.js.map +1 -1
  42. package/dist/default/SearchResultsPane.d.ts +54 -3
  43. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  44. package/dist/default/SearchResultsPane.js +45 -25
  45. package/dist/default/SearchResultsPane.js.map +1 -1
  46. package/dist/default/SortSelect.d.ts +10 -2
  47. package/dist/default/SortSelect.d.ts.map +1 -1
  48. package/dist/default/SortSelect.js +27 -24
  49. package/dist/default/SortSelect.js.map +1 -1
  50. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  51. package/dist/default/UrlIssueNotice.js +19 -1
  52. package/dist/default/UrlIssueNotice.js.map +1 -1
  53. package/dist/default/index.d.ts +33 -16
  54. package/dist/default/index.d.ts.map +1 -1
  55. package/dist/default/index.js +26 -12
  56. package/dist/default/index.js.map +1 -1
  57. package/dist/default/sortLabels.d.ts +5 -0
  58. package/dist/default/sortLabels.d.ts.map +1 -0
  59. package/dist/default/sortLabels.js +32 -0
  60. package/dist/default/sortLabels.js.map +1 -0
  61. package/dist/headless/FacetPanel.d.ts +17 -0
  62. package/dist/headless/FacetPanel.d.ts.map +1 -1
  63. package/dist/headless/FacetPanel.js +23 -2
  64. package/dist/headless/FacetPanel.js.map +1 -1
  65. package/dist/headless/SearchResults.d.ts +20 -3
  66. package/dist/headless/SearchResults.d.ts.map +1 -1
  67. package/dist/headless/SearchResults.js +3 -1
  68. package/dist/headless/SearchResults.js.map +1 -1
  69. package/dist/headless/useAppliedCount.d.ts +8 -0
  70. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  71. package/dist/headless/useAppliedCount.js +30 -0
  72. package/dist/headless/useAppliedCount.js.map +1 -0
  73. package/dist/headless/useScorerNames.d.ts +4 -0
  74. package/dist/headless/useScorerNames.d.ts.map +1 -0
  75. package/dist/headless/useScorerNames.js +34 -0
  76. package/dist/headless/useScorerNames.js.map +1 -0
  77. package/dist/headless/useSearchBox.d.ts +44 -0
  78. package/dist/headless/useSearchBox.d.ts.map +1 -0
  79. package/dist/headless/useSearchBox.js +116 -0
  80. package/dist/headless/useSearchBox.js.map +1 -0
  81. package/dist/i18n/es.d.ts.map +1 -1
  82. package/dist/i18n/es.js +32 -4
  83. package/dist/i18n/es.js.map +1 -1
  84. package/dist/i18n/keys.d.ts +46 -2
  85. package/dist/i18n/keys.d.ts.map +1 -1
  86. package/dist/i18n/keys.js +86 -6
  87. package/dist/i18n/keys.js.map +1 -1
  88. package/dist/i18n/ru.d.ts.map +1 -1
  89. package/dist/i18n/ru.js +38 -4
  90. package/dist/i18n/ru.js.map +1 -1
  91. package/dist/index.d.ts +9 -3
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +6 -3
  94. package/dist/index.js.map +1 -1
  95. package/dist/model/queries.d.ts +26 -2
  96. package/dist/model/queries.d.ts.map +1 -1
  97. package/dist/model/queries.js +38 -1
  98. package/dist/model/queries.js.map +1 -1
  99. package/dist/state/degradations.d.ts +41 -0
  100. package/dist/state/degradations.d.ts.map +1 -1
  101. package/dist/state/degradations.js +41 -0
  102. package/dist/state/degradations.js.map +1 -1
  103. package/dist/state/limits.d.ts +44 -0
  104. package/dist/state/limits.d.ts.map +1 -0
  105. package/dist/state/limits.js +44 -0
  106. package/dist/state/limits.js.map +1 -0
  107. package/dist/state/ranges.d.ts +52 -0
  108. package/dist/state/ranges.d.ts.map +1 -0
  109. package/dist/state/ranges.js +108 -0
  110. package/dist/state/ranges.js.map +1 -0
  111. package/llms.txt +16 -5
  112. package/manifest.json +245 -22
  113. package/nav-manifest.json +1 -1
  114. package/package.json +15 -10
  115. package/src/analytics/generated/events.json +1 -1
  116. package/src/api/generated/schema.ts +5 -1
  117. package/src/default/DegradationNotice.tsx +84 -14
  118. package/src/default/FacetPanelPane.tsx +343 -64
  119. package/src/default/LanguageSelect.tsx +93 -0
  120. package/src/default/PageSizeSelect.tsx +80 -0
  121. package/src/default/RangeFilterRow.tsx +148 -0
  122. package/src/default/RankingDisclosurePane.tsx +67 -35
  123. package/src/default/SearchBox.tsx +130 -0
  124. package/src/default/SearchPage.tsx +356 -41
  125. package/src/default/SearchResultCard.tsx +231 -24
  126. package/src/default/SearchResultsPane.tsx +147 -76
  127. package/src/default/SortSelect.tsx +50 -37
  128. package/src/default/UrlIssueNotice.tsx +21 -3
  129. package/src/default/index.ts +42 -16
  130. package/src/default/sortLabels.ts +34 -0
  131. package/src/headless/FacetPanel.tsx +30 -2
  132. package/src/headless/SearchResults.tsx +32 -4
  133. package/src/headless/useAppliedCount.ts +41 -0
  134. package/src/headless/useScorerNames.ts +36 -0
  135. package/src/headless/useSearchBox.ts +189 -0
  136. package/src/i18n/es.ts +39 -4
  137. package/src/i18n/keys.ts +97 -6
  138. package/src/i18n/ru.ts +45 -4
  139. package/src/index.ts +30 -3
  140. package/src/model/queries.ts +51 -1
  141. package/src/state/degradations.ts +60 -0
  142. package/src/state/limits.ts +50 -0
  143. package/src/state/ranges.ts +144 -0
  144. package/dist/default/ErrorAlert.d.ts +0 -9
  145. package/dist/default/ErrorAlert.d.ts.map +0 -1
  146. package/dist/default/ErrorAlert.js +0 -27
  147. package/dist/default/ErrorAlert.js.map +0 -1
  148. package/dist/default/theme.d.ts +0 -24
  149. package/dist/default/theme.d.ts.map +0 -1
  150. package/dist/default/theme.js +0 -39
  151. package/dist/default/theme.js.map +0 -1
  152. package/src/default/ErrorAlert.tsx +0 -46
  153. 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 with the reason named when no geo centre is set, rather than
14
- * offered and then refused.
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
- const SORT_LABEL_KEY: Readonly<Record<string, string>> = {
25
- relevance: SEARCH_I18N_KEYS.sortRelevance,
26
- newest: SEARCH_I18N_KEYS.sortNewest,
27
- price_asc: SEARCH_I18N_KEYS.sortPriceAsc,
28
- price_desc: SEARCH_I18N_KEYS.sortPriceDesc,
29
- distance: SEARCH_I18N_KEYS.sortDistance,
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
- <Flex gap={8} align="center">
59
- <Typography.Text type="secondary">
60
- {t(SEARCH_I18N_KEYS.sortLabel)}
61
- </Typography.Text>
62
- <Select<string>
63
- data-testid="search-sort"
64
- style={{ minWidth: 180 }}
65
- value={active ?? null}
66
- onChange={(next) => {
67
- setSort(next);
68
- }}
69
- options={values.map((value) => {
70
- const key = SORT_LABEL_KEY[value];
71
- const needsCentre = value === "distance" && !hasCentre;
72
- return {
73
- value,
74
- label: key !== undefined ? t(key) : value,
75
- disabled: needsCentre,
76
- // A disabled control states its reason (the ActionAvailability
77
- // canon), even inside a select.
78
- ...(needsCentre
79
- ? { title: t("error.400.search_sort_needs_center") }
80
- : {}),
81
- };
82
- })}
83
- />
84
- </Flex>
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
- message={t(SEARCH_I18N_KEYS.urlIssuesTitle)}
43
+ title={t(SEARCH_I18N_KEYS.urlIssuesTitle)}
26
44
  description={
27
- <ul style={{ margin: 0, paddingInlineStart: 20 }}>
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>
@@ -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
- * Three override levers, none of which requires forking anything:
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. The server sends
17
- * `{value: count}` and no labels; the schema that names them lives in
18
- * categories, and the container hands it in (spec §6.2 item 2).
19
- * 3. **retheming through the §68 token JSON** — every surface wraps itself
20
- * in `<SearchSkinTheme>`, so a host's regenerated `--stapel-*` custom
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 { FacetPanelPaneProps } from "./FacetPanelPane.js";
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 { DegradationNoticeProps } from "./DegradationNotice.js";
70
+ export type {
71
+ DegradationNoticeProps,
72
+ DegradationNoticeVariant,
73
+ } from "./DegradationNotice.js";
45
74
  export { UrlIssueNotice } from "./UrlIssueNotice.js";
46
- export { SortSelect } from "./SortSelect.js";
47
- export type { SortSelectProps } from "./SortSelect.js";
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 props.children({
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 { countIsEstimate, parseDegradations } from "../state/degradations.js";
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
- /** Total matches — an ESTIMATE unless {@link SearchPageInfo.exactTotal}. */
24
- readonly count: number;
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
- /** `true` when `count` must be rendered as approximate ("≈", "about"). */
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
+ }