@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/search-react",
3
- "version": "0.4.0",
3
+ "version": "0.8.0",
4
4
  "description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,9 +57,9 @@
57
57
  "limit": "10 KB"
58
58
  },
59
59
  {
60
- "name": "default — the antd skin (results, facet panel, degradation notice, ranking disclosure) must stay out of the main bundle",
60
+ "name": "default — the antd skin (query box + typeahead, filters incl. ranges/geo/category slots + the phone chip row, results incl. the view switch, ranking) must stay out of the main bundle",
61
61
  "path": "dist/default/index.js",
62
- "limit": "13 KB"
62
+ "limit": "18 KB"
63
63
  },
64
64
  {
65
65
  "name": "router — the react-router binding is opt-in; the main entry must never pull a router",
@@ -79,14 +79,18 @@
79
79
  ],
80
80
  "peerDependencies": {
81
81
  "@stapel/attributes-react": ">=0.1.0",
82
- "@stapel/core": ">=0.17.0 <1.0.0",
83
- "@stapel/tokens-antd": ">=0.5.0",
82
+ "@stapel/core": ">=0.18.1 <1.0.0",
83
+ "@stapel/image": ">=0.3.0",
84
+ "@stapel/tokens-antd": ">=0.7.0",
84
85
  "@tanstack/react-query": "^5.0.0",
85
86
  "antd": ">=5.20.0 <7",
86
87
  "react": ">=19",
87
88
  "react-router": ">=7.0.0 <8"
88
89
  },
89
90
  "peerDependenciesMeta": {
91
+ "@stapel/image": {
92
+ "optional": true
93
+ },
90
94
  "@stapel/tokens-antd": {
91
95
  "optional": true
92
96
  },
@@ -111,11 +115,12 @@
111
115
  "size-limit": "^11.2.0",
112
116
  "typescript": "^5.8.3",
113
117
  "vitest": "^3.2.4",
114
- "@stapel/attributes-react": "^0.2.0",
115
- "@stapel/showcase": "^0.2.0",
116
- "@stapel/tokens": "^0.5.0",
117
- "@stapel/tokens-antd": "^0.5.0",
118
- "@stapel/core": "^0.17.0"
118
+ "@stapel/attributes-react": "^0.3.1",
119
+ "@stapel/core": "^0.19.0",
120
+ "@stapel/image": "^0.4.2",
121
+ "@stapel/showcase": "^0.3.0",
122
+ "@stapel/tokens": "^0.5.1",
123
+ "@stapel/tokens-antd": "^0.7.0"
119
124
  },
120
125
  "engines": {
121
126
  "node": ">=22"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.4.0",
4
+ "version": "0.8.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import type { ReactElement } from "react";
31
31
  import { Alert, Flex, Typography } from "antd";
32
+ import { fontSize, spacing } from "@stapel/tokens";
32
33
  import { useT } from "@stapel/core";
33
34
  import type { SearchDegradation } from "../api/types.js";
34
35
  import { isCountNuanceOnly } from "../state/degradations.js";
@@ -47,6 +48,13 @@ export type DegradationNoticeVariant = "banner" | "inline" | "off";
47
48
  export interface DegradationNoticeProps {
48
49
  readonly degradations: readonly SearchDegradation[];
49
50
  readonly variant?: DegradationNoticeVariant;
51
+ /**
52
+ * Names a `scorer:` degradation's slug, when something on the page knows
53
+ * one. `<SearchResultsPane>` passes the ranking disclosure's own names
54
+ * (`useScorerNames`); with no answer the slug is printed, which is what a
55
+ * registry identifier is worth on its own.
56
+ */
57
+ readonly scorerName?: (slug: string) => string | undefined;
50
58
  }
51
59
 
52
60
  export function DegradationNotice(
@@ -59,25 +67,39 @@ export function DegradationNotice(
59
67
  // A count nuance is not a degraded search — the count already says "N+".
60
68
  if (isCountNuanceOnly(props.degradations)) return null;
61
69
 
70
+ const say = (degradation: SearchDegradation): string => {
71
+ const slug = degradation.scorer;
72
+ return t(degradation.messageKey, {
73
+ scorer:
74
+ slug === undefined
75
+ ? ""
76
+ : (props.scorerName?.(slug) ?? slug),
77
+ raw: degradation.raw,
78
+ });
79
+ };
80
+
81
+ // NOT `type="secondary"` in the banner: antd paints a warning Alert in the
82
+ // theme's warning tint, and grey body text on it measured under 3:1 in the
83
+ // visual pass. Inside a coloured box the readable colour is the box's own
84
+ // text colour, which is what a plain `<Typography.Text>` inherits.
62
85
  const lines = props.degradations.map((degradation) => (
63
86
  <li key={degradation.raw} data-degradation={degradation.raw}>
64
- <Typography.Text type="secondary">
65
- {t(degradation.messageKey, {
66
- scorer: degradation.scorer ?? "",
67
- raw: degradation.raw,
68
- })}
69
- </Typography.Text>
87
+ <Typography.Text>{say(degradation)}</Typography.Text>
70
88
  </li>
71
89
  ));
72
90
 
73
91
  if (variant === "inline") {
74
92
  return (
75
- <Flex vertical gap={2} data-testid="search-degraded" data-variant="inline">
76
- <Typography.Text type="secondary" style={{ fontSize: 12 }}>
93
+ <Flex vertical gap={spacing[1]} data-testid="search-degraded" data-variant="inline">
94
+ <Typography.Text type="secondary" style={{ fontSize: fontSize.xs.fontSize }}>
77
95
  {t(SEARCH_I18N_KEYS.degradedTitle)}
78
96
  </Typography.Text>
79
- <ul style={{ margin: 0, paddingInlineStart: 20, fontSize: 12 }}>
80
- {lines}
97
+ <ul style={{ margin: 0, paddingInlineStart: spacing[5], fontSize: fontSize.xs.fontSize }}>
98
+ {props.degradations.map((degradation) => (
99
+ <li key={degradation.raw} data-degradation={degradation.raw}>
100
+ <Typography.Text type="secondary">{say(degradation)}</Typography.Text>
101
+ </li>
102
+ ))}
81
103
  </ul>
82
104
  </Flex>
83
105
  );
@@ -89,9 +111,9 @@ export function DegradationNotice(
89
111
  showIcon
90
112
  data-testid="search-degraded"
91
113
  data-variant="banner"
92
- message={t(SEARCH_I18N_KEYS.degradedTitle)}
114
+ title={t(SEARCH_I18N_KEYS.degradedTitle)}
93
115
  description={
94
- <ul style={{ margin: 0, paddingInlineStart: 20 }}>{lines}</ul>
116
+ <ul style={{ margin: 0, paddingInlineStart: spacing[5] }}>{lines}</ul>
95
117
  }
96
118
  />
97
119
  );
@@ -0,0 +1,312 @@
1
+ /**
2
+ * `<FacetGroupControl>` — ONE facet group, drawn the way its own schema says
3
+ * it should be drawn.
4
+ *
5
+ * Until this release every group was the same thing: a flat column of
6
+ * checkboxes, however many options it had and whatever the category schema
7
+ * said about it. That is wrong in three ways a person meets on the first
8
+ * screen of a real catalogue:
9
+ *
10
+ * 1. **A single-choice facet is not a checkbox list.** `maxSelected: 1` is a
11
+ * fact the schema already carries, and `@stapel/attributes-react`'s editor
12
+ * already reads it to draw a segmented control rather than a set of boxes.
13
+ * A filter that says "pick one" with controls that say "pick any" teaches
14
+ * the wrong thing before the click and surprises after it.
15
+ * 2. **A hierarchical facet is not flat.** `hierarchical_select` carries a
16
+ * TREE in `config.options`; rendering its values as one alphabetical
17
+ * column hides that "Sedan" is under "Cars" and puts a child next to its
18
+ * own parent as if they were siblings.
19
+ * 3. **A 60-option facet is not a list, it is a wall.** Every catalogue has
20
+ * one — brand, model, city — and printing all of it pushes every group
21
+ * under it off the screen.
22
+ *
23
+ * ── The presentation is DERIVED, never configured here ────────────────────
24
+ *
25
+ * {@link facetGroupShape} reads the same `config` keys the attributes editor
26
+ * reads (`maxSelected`, `type`, `options`), so a facet cannot look one way in
27
+ * the filter and another way in the composer that produced the value. There is
28
+ * no `presentation` prop on this component and there must not be one: a
29
+ * per-call-site override is how the two halves drift.
30
+ *
31
+ * A group with NO schema (the host passed no `categoryFeatures`, or the slug
32
+ * is not in it) is a flat checkbox list — the honest default, and the shape
33
+ * every group had before.
34
+ */
35
+ import { useState } from "react";
36
+ import type { CSSProperties, ReactElement } from "react";
37
+ import { Button, Checkbox, Flex, Typography } from "antd";
38
+ import { useT } from "@stapel/core";
39
+ import { radii, spacing } from "@stapel/tokens";
40
+ import { featureConfig, featureType } from "@stapel/attributes-react";
41
+ import type { FeatureDef } from "@stapel/attributes-react";
42
+ import type { FacetGroup, FacetOption } from "../state/facets.js";
43
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
44
+
45
+ /**
46
+ * How many options a group shows before the rest go behind "Show all".
47
+ *
48
+ * Eight is the count at which a group stops being readable at a glance and
49
+ * starts being a scroll: it is two more than the six the visual pass measured
50
+ * as the fold on a 390px phone with the sheet's own chrome above it, which
51
+ * leaves a group of seven whole rather than truncated by one row.
52
+ */
53
+ export const FACET_VISIBLE_OPTIONS = 8;
54
+
55
+ /** The three shapes a facet group takes. */
56
+ export type FacetGroupShape = "segmented" | "nested" | "checkbox";
57
+
58
+ /** The option rows of a group, already nested where the schema nests them. */
59
+ export interface FacetOptionNode {
60
+ readonly option: FacetOption;
61
+ /** 0 for a root option; 1+ for a child of a `hierarchical_select` tree. */
62
+ readonly depth: number;
63
+ }
64
+
65
+ function numberish(value: unknown): number | undefined {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
67
+ }
68
+
69
+ /**
70
+ * The DEPTH of every value a `hierarchical_select` config declares, walked
71
+ * once — `{value: depth}`.
72
+ *
73
+ * The tree lives in `config.options[].children[]` (the same shape
74
+ * `@stapel/attributes-react`'s `Cascader` reads). The facets themselves arrive
75
+ * FLAT from the server, because an index term has no parent; the schema is the
76
+ * only thing that knows one value sits under another.
77
+ */
78
+ function optionDepths(
79
+ raw: unknown,
80
+ depth = 0,
81
+ into: Map<string, number> = new Map()
82
+ ): Map<string, number> {
83
+ if (!Array.isArray(raw)) return into;
84
+ for (const option of raw) {
85
+ if (option === null || typeof option !== "object") continue;
86
+ const entry = option as { value?: unknown; children?: unknown };
87
+ if (entry.value !== undefined && entry.value !== null) {
88
+ into.set(String(entry.value), depth);
89
+ }
90
+ optionDepths(entry.children, depth + 1, into);
91
+ }
92
+ return into;
93
+ }
94
+
95
+ /** Is this feature single-choice? The schema's own answer, not a guess. */
96
+ function singleChoice(feature: FeatureDef | undefined): boolean {
97
+ if (feature === undefined) return false;
98
+ // `maxSelected` ABSENT means unlimited — the engine's own default. Reading
99
+ // an absent key as 1 would turn every unconfigured facet into a radio group.
100
+ return numberish(featureConfig(feature)["maxSelected"]) === 1;
101
+ }
102
+
103
+ /**
104
+ * Which of the three shapes a group takes.
105
+ *
106
+ * Order matters: a hierarchical facet is nested even when it is single-choice,
107
+ * because losing the tree costs more than losing the pills.
108
+ */
109
+ export function facetGroupShape(group: FacetGroup): FacetGroupShape {
110
+ const feature = group.feature;
111
+ if (feature !== undefined && featureType(feature) === "hierarchical_select") {
112
+ return "nested";
113
+ }
114
+ return singleChoice(feature) ? "segmented" : "checkbox";
115
+ }
116
+
117
+ /** The group's options in render order, carrying the depth the schema gives. */
118
+ export function facetOptionNodes(group: FacetGroup): readonly FacetOptionNode[] {
119
+ if (facetGroupShape(group) !== "nested" || group.feature === undefined) {
120
+ return group.options.map((option) => ({ option, depth: 0 }));
121
+ }
122
+ const depths = optionDepths(featureConfig(group.feature)["options"]);
123
+ // Depth alone would leave children beside strangers: the options are
124
+ // re-ordered so each child follows its own parent, and a value the schema
125
+ // does not know keeps depth 0 rather than being hidden under someone.
126
+ const byValue = new Map(group.options.map((option) => [option.value, option]));
127
+ const out: FacetOptionNode[] = [];
128
+ const emitted = new Set<string>();
129
+ for (const [value, depth] of depths) {
130
+ const option = byValue.get(value);
131
+ if (option === undefined) continue;
132
+ out.push({ option, depth });
133
+ emitted.add(value);
134
+ }
135
+ for (const option of group.options) {
136
+ if (!emitted.has(option.value)) out.push({ option, depth: 0 });
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** A count, or the honest "we did not count this". Never a zero standing in
142
+ * for an absent answer. */
143
+ function OptionCount(props: {
144
+ readonly group: FacetGroup;
145
+ readonly option: FacetOption;
146
+ }): ReactElement {
147
+ const t = useT();
148
+ return (
149
+ <Typography.Text
150
+ type="secondary"
151
+ data-testid={`facet-count-${props.group.slug}-${props.option.value}`}
152
+ >
153
+ {props.option.count === null
154
+ ? t(SEARCH_I18N_KEYS.facetsNotCounted)
155
+ : props.option.count}
156
+ </Typography.Text>
157
+ );
158
+ }
159
+
160
+ /** The indent one level of a hierarchical facet is drawn with. */
161
+ const NEST_STEP = spacing[5];
162
+
163
+ function CheckboxRow(props: {
164
+ readonly group: FacetGroup;
165
+ readonly node: FacetOptionNode;
166
+ readonly onToggle: (slug: string, value: string) => void;
167
+ }): ReactElement {
168
+ const { group, node } = props;
169
+ return (
170
+ <Flex
171
+ justify="space-between"
172
+ align="center"
173
+ gap={spacing[2]}
174
+ style={
175
+ node.depth > 0
176
+ ? { paddingInlineStart: node.depth * NEST_STEP }
177
+ : undefined
178
+ }
179
+ data-depth={node.depth}
180
+ >
181
+ <Checkbox
182
+ checked={node.option.selected}
183
+ data-testid={`facet-option-${group.slug}-${node.option.value}`}
184
+ data-analytics="none"
185
+ data-analytics-reason="a filter is a read, not a flow step"
186
+ onChange={() => {
187
+ props.onToggle(group.slug, node.option.value);
188
+ }}
189
+ >
190
+ {node.option.label}
191
+ </Checkbox>
192
+ <OptionCount group={group} option={node.option} />
193
+ </Flex>
194
+ );
195
+ }
196
+
197
+ /**
198
+ * One pill of a single-choice group.
199
+ *
200
+ * A real `<button aria-pressed>` rather than antd's `Segmented`: the count has
201
+ * to sit inside the pill (a filter without its remaining count is the
202
+ * drill-down facet turned naive), and `Segmented` takes labels, not rows.
203
+ * `aria-pressed` is what makes a styled button a TOGGLE to a screen reader
204
+ * instead of a link-shaped thing that mysteriously changes the page.
205
+ */
206
+ function OptionPill(props: {
207
+ readonly group: FacetGroup;
208
+ readonly option: FacetOption;
209
+ readonly onToggle: (slug: string, value: string) => void;
210
+ }): ReactElement {
211
+ const { group, option } = props;
212
+ const style: CSSProperties = { borderRadius: radii.full };
213
+ return (
214
+ <Button
215
+ size="small"
216
+ shape="round"
217
+ type={option.selected ? "primary" : "default"}
218
+ aria-pressed={option.selected}
219
+ style={style}
220
+ data-testid={`facet-option-${group.slug}-${option.value}`}
221
+ data-analytics="none"
222
+ data-analytics-reason="a filter is a read, not a flow step"
223
+ onClick={() => {
224
+ props.onToggle(group.slug, option.value);
225
+ }}
226
+ >
227
+ {option.count === null ? option.label : `${option.label} ${option.count}`}
228
+ </Button>
229
+ );
230
+ }
231
+
232
+ export interface FacetGroupControlProps {
233
+ readonly group: FacetGroup;
234
+ readonly onToggle: (slug: string, value: string) => void;
235
+ /** Draw the group's own name above its options. `false` for a surface that
236
+ * has already said it — a per-group sheet whose title IS the group. */
237
+ readonly heading?: boolean;
238
+ /** How many options before "Show all". Default {@link FACET_VISIBLE_OPTIONS};
239
+ * `null` shows every option (a sheet devoted to one group has the room). */
240
+ readonly visibleOptions?: number | null;
241
+ }
242
+
243
+ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
244
+ const t = useT();
245
+ const { group } = props;
246
+ const [expanded, setExpanded] = useState(false);
247
+ const shape = facetGroupShape(group);
248
+ const nodes = facetOptionNodes(group);
249
+
250
+ const limit =
251
+ props.visibleOptions === null
252
+ ? null
253
+ : (props.visibleOptions ?? FACET_VISIBLE_OPTIONS);
254
+ // A group one row over the limit is not truncated: hiding a single option
255
+ // behind "Show all (9)" costs a tap to reveal exactly one thing.
256
+ const collapsible = limit !== null && nodes.length > limit + 1;
257
+ const shown = collapsible && !expanded ? nodes.slice(0, limit) : nodes;
258
+
259
+ return (
260
+ <Flex
261
+ vertical
262
+ gap={shape === "segmented" ? spacing[2] : spacing[1]}
263
+ data-testid={`facet-group-${group.slug}`}
264
+ data-counted={group.counted ? "true" : "false"}
265
+ data-shape={shape}
266
+ >
267
+ {props.heading !== false && (
268
+ <Typography.Text strong>{group.label}</Typography.Text>
269
+ )}
270
+
271
+ {shape === "segmented" ? (
272
+ <Flex wrap gap={spacing[2]}>
273
+ {shown.map((node) => (
274
+ <OptionPill
275
+ key={node.option.value}
276
+ group={group}
277
+ option={node.option}
278
+ onToggle={props.onToggle}
279
+ />
280
+ ))}
281
+ </Flex>
282
+ ) : (
283
+ shown.map((node) => (
284
+ <CheckboxRow
285
+ key={node.option.value}
286
+ group={group}
287
+ node={node}
288
+ onToggle={props.onToggle}
289
+ />
290
+ ))
291
+ )}
292
+
293
+ {collapsible && (
294
+ <Button
295
+ type="link"
296
+ size="small"
297
+ style={{ alignSelf: "flex-start", paddingInline: 0 }}
298
+ data-testid={`facet-more-${group.slug}`}
299
+ data-analytics="none"
300
+ data-analytics-reason="expanding a filter group is a read, not a flow step"
301
+ onClick={() => {
302
+ setExpanded((was) => !was);
303
+ }}
304
+ >
305
+ {expanded
306
+ ? t(SEARCH_I18N_KEYS.facetsShowLess)
307
+ : t(SEARCH_I18N_KEYS.facetsShowAll, { count: nodes.length })}
308
+ </Button>
309
+ )}
310
+ </Flex>
311
+ );
312
+ }