@stapel/search-react 0.15.0 → 0.18.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 (61) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/dist/api/generated/schema.d.ts +20 -10
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FacetGroupControl.d.ts +10 -0
  5. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  6. package/dist/default/FacetGroupControl.js +122 -27
  7. package/dist/default/FacetGroupControl.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts +49 -35
  9. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  10. package/dist/default/FacetPanelPane.js +158 -7
  11. package/dist/default/FacetPanelPane.js.map +1 -1
  12. package/dist/default/FilterChips.d.ts +25 -0
  13. package/dist/default/FilterChips.d.ts.map +1 -1
  14. package/dist/default/FilterChips.js +39 -3
  15. package/dist/default/FilterChips.js.map +1 -1
  16. package/dist/default/SearchPage.d.ts +3 -0
  17. package/dist/default/SearchPage.d.ts.map +1 -1
  18. package/dist/default/SearchPage.js +15 -4
  19. package/dist/default/SearchPage.js.map +1 -1
  20. package/dist/default/SearchResultsPane.d.ts +9 -1
  21. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  22. package/dist/default/SearchResultsPane.js +20 -7
  23. package/dist/default/SearchResultsPane.js.map +1 -1
  24. package/dist/default/index.d.ts +1 -1
  25. package/dist/default/index.d.ts.map +1 -1
  26. package/dist/default/index.js +1 -1
  27. package/dist/default/index.js.map +1 -1
  28. package/dist/headless/FacetPanel.d.ts.map +1 -1
  29. package/dist/headless/FacetPanel.js +1 -0
  30. package/dist/headless/FacetPanel.js.map +1 -1
  31. package/dist/i18n/es.d.ts.map +1 -1
  32. package/dist/i18n/es.js +5 -0
  33. package/dist/i18n/es.js.map +1 -1
  34. package/dist/i18n/keys.d.ts +19 -0
  35. package/dist/i18n/keys.d.ts.map +1 -1
  36. package/dist/i18n/keys.js +25 -0
  37. package/dist/i18n/keys.js.map +1 -1
  38. package/dist/i18n/ru.d.ts.map +1 -1
  39. package/dist/i18n/ru.js +7 -0
  40. package/dist/i18n/ru.js.map +1 -1
  41. package/dist/state/facets.d.ts +14 -0
  42. package/dist/state/facets.d.ts.map +1 -1
  43. package/dist/state/facets.js +59 -10
  44. package/dist/state/facets.js.map +1 -1
  45. package/llms.txt +2 -2
  46. package/manifest.json +8 -2
  47. package/nav-manifest.json +1 -1
  48. package/package.json +7 -7
  49. package/src/analytics/generated/events.json +1 -1
  50. package/src/api/generated/schema.ts +20 -10
  51. package/src/default/FacetGroupControl.tsx +217 -60
  52. package/src/default/FacetPanelPane.tsx +255 -27
  53. package/src/default/FilterChips.tsx +69 -2
  54. package/src/default/SearchPage.tsx +24 -1
  55. package/src/default/SearchResultsPane.tsx +20 -7
  56. package/src/default/index.ts +2 -0
  57. package/src/headless/FacetPanel.tsx +1 -0
  58. package/src/i18n/es.ts +5 -0
  59. package/src/i18n/keys.ts +25 -0
  60. package/src/i18n/ru.ts +7 -0
  61. package/src/state/facets.ts +55 -8
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/search-react",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "entries": [
5
5
  {
6
6
  "id": "search.results",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/search-react",
3
- "version": "0.15.0",
3
+ "version": "0.18.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": "11 KB"
58
58
  },
59
59
  {
60
- "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking) must stay out of the main bundle",
60
+ "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking) must stay out of the main bundle",
61
61
  "path": "dist/default/index.js",
62
- "limit": "21 KB"
62
+ "limit": "22 KB"
63
63
  },
64
64
  {
65
65
  "name": "router — the react-router binding is opt-in; the main entry must never pull a router",
@@ -115,12 +115,12 @@
115
115
  "size-limit": "^11.2.0",
116
116
  "typescript": "^5.8.3",
117
117
  "vitest": "^3.2.4",
118
- "@stapel/core": "^0.22.0",
118
+ "@stapel/attributes-react": "^0.11.1",
119
+ "@stapel/core": "^0.23.1",
119
120
  "@stapel/image": "^0.4.2",
120
121
  "@stapel/showcase": "^0.3.0",
121
- "@stapel/attributes-react": "^0.8.1",
122
- "@stapel/tokens": "^0.6.0",
123
- "@stapel/tokens-antd": "^0.11.0"
122
+ "@stapel/tokens": "^0.7.0",
123
+ "@stapel/tokens-antd": "^0.14.0"
124
124
  },
125
125
  "engines": {
126
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.15.0",
4
+ "version": "0.18.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -137,25 +137,29 @@ export interface components {
137
137
  schemas: {
138
138
  /** @description One destination in the dropdown, ready to render and ready to follow. */
139
139
  CategorySuggestion: {
140
- /** @description Category id. */
140
+ /** @description Category id. A `listings`-graded row derives it from the path's leaf segment. */
141
141
  id: number;
142
- /** @description Category slug. */
142
+ /** @description Category slug. Empty on a `listings`-graded row: no comm Function in the fleet resolves a path id to its slug or name yet. */
143
143
  slug: string;
144
- /** @description The category's own display name. */
144
+ /** @description The category's own display name. On a `listings`-graded row this is the leaf id as a truthful segment — render such rows by their `count` instead. */
145
145
  name: string;
146
- /** @description Display names root->leaf, e.g. ['Мужская одежда', 'Шорты']. This is what distinguishes three categories that share a name. */
146
+ /** @description Display names root->leaf, e.g. ['Мужская одежда', 'Шорты']. This is what distinguishes three categories that share a name. `listings`-graded rows carry the id segments here. */
147
147
  path: string[];
148
148
  /** @description The ancestry as ids joined with '/'. Pass it verbatim as the `category` parameter of /query — do not re-join path segments yourself. */
149
149
  category: string;
150
- /** @description Live listings a buyer would see under this category, descendants included — the same number the SERP reports for it. */
150
+ /** @description Live listings a buyer would see under this category, descendants included — the same number the SERP reports for it. On a `listings`-graded row: how many of them match the typed query, which is the count a `?q=…&category=…` tap will show. */
151
151
  count: number;
152
152
  /** @description Number of segments in `path`. */
153
153
  depth: number;
154
154
  /**
155
- * @description How the name matched. Informational; ranking is by `count`.
155
+ * @description How the row earned its place: the four name grades from `categories.suggest`, or `listings` a goods-driven row, offered because documents matching the query live there even though no category name says the word. Ranking: strong grades (exact/prefix/word) and `listings` sort as one class above `substring`; within a class, stocked before empty, then grade, then count desc, then depth, then name. `vector` rows — embedding-space neighbours offered when nothing first-class matched — always sit below every deterministic row, ordered by similarity.
156
156
  *
157
+ * * `exact` - exact
157
158
  * * `prefix` - prefix
159
+ * * `word` - word
158
160
  * * `substring` - substring
161
+ * * `listings` - listings
162
+ * * `vector` - vector
159
163
  */
160
164
  match: components["schemas"]["MatchEnum"];
161
165
  };
@@ -175,6 +179,8 @@ export interface components {
175
179
  counted: string[];
176
180
  /** @description Plan slugs dropped at MAX_FACET_FIELDS — reported, not vanished. */
177
181
  skipped: string[];
182
+ /** @description `f.<slug>`/`r.<slug>` filters this answer did NOT apply, because the category marks the slug non-public (`FeatureDef.visibility` is `owner` or `staff`). A hidden value is not indexed and is not filterable: letting `?f.vin=<value>` through would make the index an exact-match oracle over an identifier. The answer is wider than what was asked for, and says so here rather than silently. */
183
+ dropped_filters: string[];
178
184
  /** @description Range slugs that address a core document column rather than an attribute (`r.price`). Offer them as filters unconditionally: they exist for every document in every category, which is why they are not in the category's own plan. */
179
185
  core_ranges: string[];
180
186
  };
@@ -191,11 +197,15 @@ export interface components {
191
197
  stale_reason?: string;
192
198
  };
193
199
  /**
194
- * @description * `prefix` - prefix
200
+ * @description * `exact` - exact
201
+ * * `prefix` - prefix
202
+ * * `word` - word
195
203
  * * `substring` - substring
204
+ * * `listings` - listings
205
+ * * `vector` - vector
196
206
  * @enum {string}
197
207
  */
198
- MatchEnum: "prefix" | "substring";
208
+ MatchEnum: "exact" | "prefix" | "word" | "substring" | "listings" | "vector";
199
209
  /** @description The P2B Art. 5 disclosure, generated from the scorer registry. */
200
210
  RankingResponse: {
201
211
  doc_type: string;
@@ -283,7 +293,7 @@ export interface components {
283
293
  took_ms: number;
284
294
  };
285
295
  SuggestResponse: {
286
- /** @description Destinations, ranked by live listing count desc, then depth, then name. */
296
+ /** @description Destinations, ranked by match class (strong name grades and goods-driven rows above substring), then stocked before empty, then grade, then live listing count desc, then depth, then name. */
287
297
  categories: components["schemas"]["CategorySuggestion"][];
288
298
  /** @description Title prefixes from the index. */
289
299
  terms: string[];
@@ -291,7 +301,7 @@ export interface components {
291
301
  items: string[];
292
302
  /** @description Which dictionary answered — the same resolution /query reports. */
293
303
  language: string;
294
- /** @description What this answer could not do: `category_suggestions` (no provider for category names), `category_rollup` (no ancestry, so counts would read 0). */
304
+ /** @description What this answer could not do: `category_suggestions` (no provider for category names), `category_rollup` (no ancestry, so counts would read 0), `category_listing_suggestions` (no name matched and the configured engine does not implement the optional goods-driven verb, or it failed). */
295
305
  degraded: string[];
296
306
  backend: string;
297
307
  };
@@ -31,6 +31,31 @@
31
31
  * A group with NO schema (the host passed no `categoryFeatures`, or the slug
32
32
  * is not in it) is a flat checkbox list — the honest default, and the shape
33
33
  * every group had before.
34
+ *
35
+ * ── The group can be a disclosure, and closed it is NOT in the DOM ─────────
36
+ *
37
+ * Measured on a live classified deployment's cars leaf at 1440×900: the 280px
38
+ * rail carried 5717px of content — 40 groups, 118 checkboxes, 66 fields — as
39
+ * one flat column. No amount of per-group folding fixes forty headings' worth
40
+ * of open controls, so the group itself can close: `collapsible` turns the
41
+ * label into a real `<button aria-expanded>` with a chevron and, when values
42
+ * are chosen inside, the count of them — the one fact a closed group owes its
43
+ * header. Closed means the options are not rendered at all: a hundred
44
+ * `display:none` checkboxes are still a hundred stops for a screen reader.
45
+ * Both props default to today's behaviour (always open, no disclosure), so no
46
+ * existing host changes; WHICH groups open is the panel's decision, not this
47
+ * component's — see `FacetPanelPane`.
48
+ *
49
+ * ── Uncounted options are the fold's tail, not the group's face ────────────
50
+ *
51
+ * The same walk found "not counted" printed 100+ times down the default view.
52
+ * The sentence is honest and it stays — but an option with no evidence behind
53
+ * it must not stand in front of one that has some, so options with
54
+ * `count: null` and nothing chosen sort AFTER every counted one and live
55
+ * behind the existing "Show all (N)" fold. A group whose options are ALL
56
+ * uncounted (a schema-only group — the server never counted the slug) keeps
57
+ * them visible as before: folding everything would leave a heading over
58
+ * nothing. Chosen options are always visible, wherever their count went.
34
59
  */
35
60
  import { useState } from "react";
36
61
  import type { CSSProperties, ReactElement } from "react";
@@ -114,28 +139,50 @@ export function facetGroupShape(group: FacetGroup): FacetGroupShape {
114
139
  return singleChoice(feature) ? "segmented" : "checkbox";
115
140
  }
116
141
 
142
+ /**
143
+ * Is this node in the group's uncounted tail — an option with no evidence
144
+ * behind it AND no choice on it? Only meaningful in a group that has counted
145
+ * evidence at all: see {@link facetOptionNodes}.
146
+ */
147
+ function uncountedUnchosen(node: FacetOptionNode): boolean {
148
+ return node.option.count === null && !node.option.selected;
149
+ }
150
+
117
151
  /** The group's options in render order, carrying the depth the schema gives. */
118
152
  export function facetOptionNodes(group: FacetGroup): readonly FacetOptionNode[] {
153
+ let nodes: readonly FacetOptionNode[];
119
154
  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 });
155
+ nodes = group.options.map((option) => ({ option, depth: 0 }));
156
+ } else {
157
+ const depths = optionDepths(featureConfig(group.feature)["options"]);
158
+ // Depth alone would leave children beside strangers: the options are
159
+ // re-ordered so each child follows its own parent, and a value the schema
160
+ // does not know keeps depth 0 rather than being hidden under someone.
161
+ const byValue = new Map(group.options.map((option) => [option.value, option]));
162
+ const out: FacetOptionNode[] = [];
163
+ const emitted = new Set<string>();
164
+ for (const [value, depth] of depths) {
165
+ const option = byValue.get(value);
166
+ if (option === undefined) continue;
167
+ out.push({ option, depth });
168
+ emitted.add(value);
169
+ }
170
+ for (const option of group.options) {
171
+ if (!emitted.has(option.value)) out.push({ option, depth: 0 });
172
+ }
173
+ nodes = out;
137
174
  }
138
- return out;
175
+
176
+ // An option nobody counted must not stand in front of one with evidence:
177
+ // the uncounted, unchosen tail sorts after everything else (stable — both
178
+ // halves keep their own order) and folds behind "Show all" in the
179
+ // component below. Only in a group that HAS counted evidence: a schema-only
180
+ // group is all `null`, and demoting all of it would reorder nothing while
181
+ // telling the fold to hide the entire group behind its own heading.
182
+ if (!group.options.some((option) => option.count !== null)) return nodes;
183
+ const tail = nodes.filter(uncountedUnchosen);
184
+ if (tail.length === 0) return nodes;
185
+ return [...nodes.filter((node) => !uncountedUnchosen(node)), ...tail];
139
186
  }
140
187
 
141
188
  /** A count, or the honest "we did not count this". Never a zero standing in
@@ -229,6 +276,57 @@ function OptionPill(props: {
229
276
  );
230
277
  }
231
278
 
279
+ /**
280
+ * The disclosure header, as a NATIVE button with the chrome stripped.
281
+ *
282
+ * Native rather than antd's `Button`, because this is a heading that happens
283
+ * to toggle, not an action: it must inherit the heading's type and colour and
284
+ * sit flush with the group's left edge, and undoing a themed button's
285
+ * padding, border, background and hover for every surface it lands on is more
286
+ * style than the button brings. `aria-expanded` on a real `<button>` is the
287
+ * whole disclosure pattern; the chevron only draws what it already says.
288
+ */
289
+ const DISCLOSURE_HEADER: CSSProperties = {
290
+ display: "flex",
291
+ alignItems: "center",
292
+ gap: spacing[1],
293
+ width: "100%",
294
+ padding: 0,
295
+ border: "none",
296
+ background: "none",
297
+ color: "inherit",
298
+ font: "inherit",
299
+ textAlign: "start",
300
+ cursor: "pointer",
301
+ };
302
+
303
+ /** The disclosure's state, drawn. Points down over a closed group ("there is
304
+ * more below") and flips once it is open. */
305
+ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
306
+ return (
307
+ <svg
308
+ width="16"
309
+ height="16"
310
+ viewBox="0 0 24 24"
311
+ fill="none"
312
+ stroke="currentColor"
313
+ strokeWidth="2"
314
+ strokeLinecap="round"
315
+ strokeLinejoin="round"
316
+ role="img"
317
+ aria-hidden="true"
318
+ focusable="false"
319
+ style={{
320
+ marginInlineStart: "auto",
321
+ flex: "0 0 auto",
322
+ ...(props.open ? { transform: "rotate(180deg)" } : {}),
323
+ }}
324
+ >
325
+ <path d="m6 9 6 6 6-6" />
326
+ </svg>
327
+ );
328
+ }
329
+
232
330
  export interface FacetGroupControlProps {
233
331
  readonly group: FacetGroup;
234
332
  readonly onToggle: (slug: string, value: string) => void;
@@ -238,23 +336,48 @@ export interface FacetGroupControlProps {
238
336
  /** How many options before "Show all". Default {@link FACET_VISIBLE_OPTIONS};
239
337
  * `null` shows every option (a sheet devoted to one group has the room). */
240
338
  readonly visibleOptions?: number | null;
339
+ /**
340
+ * Make the group a disclosure: the heading becomes a real button and the
341
+ * options can leave the DOM entirely. Default `false` — today's always-open
342
+ * group, so no existing host changes. Meaningless with `heading: false`:
343
+ * a disclosure with no header is a group nothing can reopen.
344
+ */
345
+ readonly collapsible?: boolean;
346
+ /** Whether a `collapsible` group STARTS open. Default `true`. The initial
347
+ * value only — the person owns the state after the first click. */
348
+ readonly defaultOpen?: boolean;
241
349
  }
242
350
 
243
351
  export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
244
352
  const t = useT();
245
353
  const { group } = props;
246
354
  const [expanded, setExpanded] = useState(false);
355
+ const [openState, setOpenState] = useState(props.defaultOpen !== false);
247
356
  const shape = facetGroupShape(group);
248
357
  const nodes = facetOptionNodes(group);
249
358
 
359
+ const disclosure = props.collapsible === true && props.heading !== false;
360
+ const open = !disclosure || openState;
361
+
250
362
  const limit =
251
363
  props.visibleOptions === null
252
364
  ? null
253
365
  : (props.visibleOptions ?? FACET_VISIBLE_OPTIONS);
366
+ // The demoted tail `facetOptionNodes` sorted to the end: uncounted,
367
+ // unchosen options in a group that has counted evidence. They are ALWAYS
368
+ // behind the fold — the "one row over the limit" exemption below is about
369
+ // not hiding one counted row, and these rows say "not counted".
370
+ const demoted = group.options.some((option) => option.count !== null)
371
+ ? nodes.filter(uncountedUnchosen).length
372
+ : 0;
254
373
  // A group one row over the limit is not truncated: hiding a single option
255
374
  // 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;
375
+ const folded =
376
+ limit !== null && (nodes.length > limit + 1 || demoted > 0);
377
+ const shown =
378
+ folded && !expanded
379
+ ? nodes.slice(0, Math.min(limit ?? nodes.length, nodes.length - demoted))
380
+ : nodes;
258
381
 
259
382
  return (
260
383
  <Flex
@@ -264,48 +387,82 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
264
387
  data-counted={group.counted ? "true" : "false"}
265
388
  data-shape={shape}
266
389
  >
267
- {props.heading !== false && (
268
- <Typography.Text strong>{group.label}</Typography.Text>
269
- )}
390
+ {props.heading !== false &&
391
+ (disclosure ? (
392
+ <button
393
+ type="button"
394
+ style={DISCLOSURE_HEADER}
395
+ aria-expanded={open}
396
+ data-testid={`facet-toggle-${group.slug}`}
397
+ data-analytics="none"
398
+ data-analytics-reason="opening a filter group is a read, not a flow step"
399
+ onClick={() => {
400
+ setOpenState((was) => !was);
401
+ }}
402
+ >
403
+ <Typography.Text strong>{group.label}</Typography.Text>
404
+ {/* The one fact a closed group owes its header: how many of its
405
+ values are CHOSEN. Not the option count — that is what the
406
+ fold's own "Show all (N)" answers once the group is open. */}
407
+ {group.selected.length > 0 && (
408
+ <Typography.Text
409
+ type="secondary"
410
+ data-testid={`facet-toggle-count-${group.slug}`}
411
+ >
412
+ {group.selected.length}
413
+ </Typography.Text>
414
+ )}
415
+ <ChevronGlyph open={open} />
416
+ </button>
417
+ ) : (
418
+ <Typography.Text strong>{group.label}</Typography.Text>
419
+ ))}
270
420
 
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
- )}
421
+ {/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
422
+ checkboxes are still a hundred stops for a screen reader, and the
423
+ measured rail held 118 of them. */}
424
+ {open && (
425
+ <>
426
+ {shape === "segmented" ? (
427
+ <Flex wrap gap={spacing[2]}>
428
+ {shown.map((node) => (
429
+ <OptionPill
430
+ key={node.option.value}
431
+ group={group}
432
+ option={node.option}
433
+ onToggle={props.onToggle}
434
+ />
435
+ ))}
436
+ </Flex>
437
+ ) : (
438
+ shown.map((node) => (
439
+ <CheckboxRow
440
+ key={node.option.value}
441
+ group={group}
442
+ node={node}
443
+ onToggle={props.onToggle}
444
+ />
445
+ ))
446
+ )}
292
447
 
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>
448
+ {folded && (
449
+ <Button
450
+ type="link"
451
+ size="small"
452
+ style={{ alignSelf: "flex-start", paddingInline: 0 }}
453
+ data-testid={`facet-more-${group.slug}`}
454
+ data-analytics="none"
455
+ data-analytics-reason="expanding a filter group is a read, not a flow step"
456
+ onClick={() => {
457
+ setExpanded((was) => !was);
458
+ }}
459
+ >
460
+ {expanded
461
+ ? t(SEARCH_I18N_KEYS.facetsShowLess)
462
+ : t(SEARCH_I18N_KEYS.facetsShowAll, { count: nodes.length })}
463
+ </Button>
464
+ )}
465
+ </>
309
466
  )}
310
467
  </Flex>
311
468
  );