@stapel/search-react 0.31.0 → 0.32.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 (89) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/api/generated/schema.d.ts +69 -6
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +118 -33
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js +13 -0
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/FacetGroupControl.d.ts +18 -1
  9. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  10. package/dist/default/FacetGroupControl.js +25 -0
  11. package/dist/default/FacetGroupControl.js.map +1 -1
  12. package/dist/default/FacetPanelPane.d.ts +16 -0
  13. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  14. package/dist/default/FacetPanelPane.js +94 -19
  15. package/dist/default/FacetPanelPane.js.map +1 -1
  16. package/dist/default/FilterChips.d.ts +22 -0
  17. package/dist/default/FilterChips.d.ts.map +1 -1
  18. package/dist/default/FilterChips.js +31 -2
  19. package/dist/default/FilterChips.js.map +1 -1
  20. package/dist/default/PopularValues.d.ts +49 -2
  21. package/dist/default/PopularValues.d.ts.map +1 -1
  22. package/dist/default/PopularValues.js +57 -3
  23. package/dist/default/PopularValues.js.map +1 -1
  24. package/dist/default/RangeFilterRow.d.ts +25 -0
  25. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  26. package/dist/default/RangeFilterRow.js +77 -5
  27. package/dist/default/RangeFilterRow.js.map +1 -1
  28. package/dist/default/SearchPage.d.ts +28 -1
  29. package/dist/default/SearchPage.d.ts.map +1 -1
  30. package/dist/default/SearchPage.js +13 -6
  31. package/dist/default/SearchPage.js.map +1 -1
  32. package/dist/default/SearchResultsPane.d.ts +10 -0
  33. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  34. package/dist/default/SearchResultsPane.js +1 -1
  35. package/dist/default/SearchResultsPane.js.map +1 -1
  36. package/dist/default/SortSelect.d.ts +10 -10
  37. package/dist/default/SortSelect.d.ts.map +1 -1
  38. package/dist/default/SortSelect.js +19 -8
  39. package/dist/default/SortSelect.js.map +1 -1
  40. package/dist/default/index.d.ts +4 -4
  41. package/dist/default/index.d.ts.map +1 -1
  42. package/dist/default/index.js +3 -3
  43. package/dist/default/index.js.map +1 -1
  44. package/dist/headless/FacetPanel.d.ts +7 -0
  45. package/dist/headless/FacetPanel.d.ts.map +1 -1
  46. package/dist/headless/FacetPanel.js +2 -1
  47. package/dist/headless/FacetPanel.js.map +1 -1
  48. package/dist/headless/SearchStateProvider.d.ts +15 -0
  49. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  50. package/dist/headless/SearchStateProvider.js +0 -0
  51. package/dist/headless/SearchStateProvider.js.map +1 -1
  52. package/dist/index.d.ts +4 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/state/facets.d.ts +13 -0
  57. package/dist/state/facets.d.ts.map +1 -1
  58. package/dist/state/facets.js +13 -0
  59. package/dist/state/facets.js.map +1 -1
  60. package/dist/state/panel.d.ts +79 -0
  61. package/dist/state/panel.d.ts.map +1 -0
  62. package/dist/state/panel.js +75 -0
  63. package/dist/state/panel.js.map +1 -0
  64. package/dist/state/ranges.d.ts +55 -3
  65. package/dist/state/ranges.d.ts.map +1 -1
  66. package/dist/state/ranges.js +78 -20
  67. package/dist/state/ranges.js.map +1 -1
  68. package/llms.txt +4 -4
  69. package/manifest.json +12 -2
  70. package/nav-manifest.json +1 -1
  71. package/package.json +9 -9
  72. package/src/analytics/generated/events.json +1 -1
  73. package/src/api/generated/schema.ts +69 -6
  74. package/src/api/types.ts +132 -32
  75. package/src/default/FacetGroupControl.tsx +29 -1
  76. package/src/default/FacetPanelPane.tsx +240 -124
  77. package/src/default/FilterChips.tsx +39 -1
  78. package/src/default/PopularValues.tsx +88 -4
  79. package/src/default/RangeFilterRow.tsx +117 -20
  80. package/src/default/SearchPage.tsx +46 -4
  81. package/src/default/SearchResultsPane.tsx +13 -0
  82. package/src/default/SortSelect.tsx +29 -19
  83. package/src/default/index.ts +8 -1
  84. package/src/headless/FacetPanel.tsx +17 -2
  85. package/src/headless/SearchStateProvider.tsx +23 -0
  86. package/src/index.ts +8 -0
  87. package/src/state/facets.ts +27 -0
  88. package/src/state/panel.ts +138 -0
  89. package/src/state/ranges.ts +158 -47
@@ -18,6 +18,24 @@
18
18
  * the picker never had — which is also what makes "from > to" refusable
19
19
  * instead of merely empty. A blur that changed nothing sends nothing, and
20
20
  * Enter followed by the blur it does not itself cause never double-commits.
21
+ *
22
+ * ── One Apply for the panel, not one per row ──────────────────────────────
23
+ *
24
+ * That is the row STANDING ALONE. Inside a filter panel it is one of several,
25
+ * and a rail with a price, a year and a mileage drew three identical
26
+ * "Apply" buttons stacked down a 280px column — one per row, each
27
+ * committing its own two fields, so narrowing by two axes was two searches and
28
+ * two history entries, and the button beside the row a person had just typed
29
+ * into looked like the one that would apply everything.
30
+ *
31
+ * So a panel passes {@link RangeFilterRowProps.onDraft} and owns the button:
32
+ * the row then REPORTS its draft on every change and commits nothing by
33
+ * itself. Blur stops committing (there is a button now, and blurring one field
34
+ * to reach the next is not a decision), Enter reaches the panel's own commit
35
+ * through {@link RangeFilterRowProps.onCommit}, and "Clear" stays on the
36
+ * row because removing one applied constraint is not the same act as applying
37
+ * a draft. The standalone shape is unchanged, and is still what a host mounting
38
+ * one row gets.
21
39
  */
22
40
  import { useRef, useState } from "react";
23
41
  import type { ReactElement } from "react";
@@ -77,10 +95,36 @@ export function RangeRowSkeleton(): ReactElement {
77
95
  );
78
96
  }
79
97
 
98
+ /** What one row reports to a panel that owns the Apply button. */
99
+ export interface RangeDraft {
100
+ /** The two ends as typed, or `null` for "clear this slug". */
101
+ readonly range: SearchRange | null;
102
+ /** `false` for `100..50` — a pair the server would answer zero for, which
103
+ * reads as "there is nothing like this" instead of "you typed it
104
+ * backwards". A panel refuses to apply while any draft is unusable. */
105
+ readonly usable: boolean;
106
+ /** `true` when the draft differs from what the URL already carries — what
107
+ * makes the panel's button primary, and what stops an Apply over untouched
108
+ * rows sending anything. */
109
+ readonly dirty: boolean;
110
+ }
111
+
80
112
  export interface RangeFilterRowProps {
81
113
  readonly group: RangeGroup;
82
114
  /** `null` clears the slug's range entirely. */
83
115
  readonly onApply: (slug: string, range: SearchRange | null) => void;
116
+ /**
117
+ * Report this row's draft instead of owning an Apply button — see the
118
+ * module note. Called on every change, and once on mount is NOT promised: a
119
+ * panel starts from "nothing pending", which is what an untouched row means.
120
+ */
121
+ readonly onDraft?: (slug: string, draft: RangeDraft) => void;
122
+ /**
123
+ * Commit everything the panel has collected. Only reachable in the collected
124
+ * shape, and only from Enter — the gesture that has always meant "I have
125
+ * finished typing this".
126
+ */
127
+ readonly onCommit?: () => void;
84
128
  }
85
129
 
86
130
  function toDraft(value: string | undefined): string {
@@ -243,6 +287,10 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
243
287
  const apply: ActionAvailability = usable
244
288
  ? actionAvailable()
245
289
  : actionBlocked(SEARCH_I18N_KEYS.facetsRangeInvalid);
290
+ // Who owns the Apply button — see the module note. `onDraft` is the panel
291
+ // saying "I have one for all of us".
292
+ const onDraft = props.onDraft;
293
+ const collected = onDraft !== undefined;
246
294
 
247
295
  const commit = (): void => {
248
296
  if (!usable) return;
@@ -252,6 +300,45 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
252
300
  props.onApply(group.slug, empty ? null : draft);
253
301
  };
254
302
 
303
+ /**
304
+ * Both halves of a change, in the shape the row is in.
305
+ *
306
+ * Standalone, a bound moves the local draft and nothing else until Apply,
307
+ * Enter or blur. Collected, the same move is also REPORTED — synchronously,
308
+ * from the event, with the values the change produces rather than the ones
309
+ * state still holds, because `setFrom` has not landed yet and a panel told
310
+ * the previous value would apply the number before last.
311
+ */
312
+ const change = (bound: "from" | "to", value: string): void => {
313
+ if (bound === "from") setFrom(value);
314
+ else setTo(value);
315
+ if (onDraft === undefined) return;
316
+ const nextFrom = bound === "from" ? value : from;
317
+ const nextTo = bound === "to" ? value : to;
318
+ const next: SearchRange = {
319
+ ...(nextFrom !== "" ? { from: nextFrom } : {}),
320
+ ...(nextTo !== "" ? { to: nextTo } : {}),
321
+ };
322
+ const blank = nextFrom === "" && nextTo === "";
323
+ onDraft(group.slug, {
324
+ range: blank ? null : next,
325
+ usable: isRangeUsable(next),
326
+ dirty: `${nextFrom}..${nextTo}` !== current,
327
+ });
328
+ };
329
+
330
+ /** Enter. The panel's commit when there is one, this row's otherwise. */
331
+ const enter = (): void => {
332
+ if (collected) props.onCommit?.();
333
+ else commit();
334
+ };
335
+
336
+ /** Blur. Nothing at all once a button owns the decision — leaving a field to
337
+ * reach the next one is not "apply this". */
338
+ const blur = (): void => {
339
+ if (!collected) commit();
340
+ };
341
+
255
342
  const suffix = boundSuffix(format, group);
256
343
  const unit = suffix === undefined ? "" : ` ${suffix}`;
257
344
  // Thousands grouping inside the field, because a price is read in
@@ -294,15 +381,19 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
294
381
  group={group}
295
382
  bound="from"
296
383
  value={from}
297
- onChange={setFrom}
298
- onCommit={commit}
384
+ onChange={(value) => {
385
+ change("from", value);
386
+ }}
387
+ onCommit={blur}
299
388
  />
300
389
  <BoundPicker
301
390
  group={group}
302
391
  bound="to"
303
392
  value={to}
304
- onChange={setTo}
305
- onCommit={commit}
393
+ onChange={(value) => {
394
+ change("to", value);
395
+ }}
396
+ onCommit={blur}
306
397
  />
307
398
  </>
308
399
  ) : (
@@ -320,10 +411,10 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
320
411
  {...(group.max !== undefined ? { max: group.max } : {})}
321
412
  {...(group.step !== undefined ? { step: group.step } : {})}
322
413
  onChange={(value) => {
323
- setFrom(value === null || value === undefined ? "" : String(value));
414
+ change("from", value === null || value === undefined ? "" : String(value));
324
415
  }}
325
- onPressEnter={commit}
326
- onBlur={commit}
416
+ onPressEnter={enter}
417
+ onBlur={blur}
327
418
  />
328
419
  <InputNumber
329
420
  value={to === "" ? null : Number(to)}
@@ -338,10 +429,10 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
338
429
  {...(group.max !== undefined ? { max: group.max } : {})}
339
430
  {...(group.step !== undefined ? { step: group.step } : {})}
340
431
  onChange={(value) => {
341
- setTo(value === null || value === undefined ? "" : String(value));
432
+ change("to", value === null || value === undefined ? "" : String(value));
342
433
  }}
343
- onPressEnter={commit}
344
- onBlur={commit}
434
+ onPressEnter={enter}
435
+ onBlur={blur}
345
436
  />
346
437
  </>
347
438
  )}
@@ -351,22 +442,28 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
351
442
  button exists to submit (class C-NOPRIMARY). And no `size="small"`:
352
443
  a filter row that a phone cannot hit is not a filter row, and the
353
444
  shared `SkinTheme` only raises the DEFAULT control height to 44. */}
354
- <GatedButton
355
- gate={apply}
356
- type={usable && !empty ? "primary" : "default"}
357
- testId={`facet-range-${group.slug}-apply`}
358
- data-analytics="none"
359
- data-analytics-reason="a filter is a read, not a flow step"
360
- onClick={commit}
361
- >
362
- {t(SEARCH_I18N_KEYS.facetsRangeApply)}
363
- </GatedButton>
445
+ {!collected && (
446
+ <GatedButton
447
+ gate={apply}
448
+ type={usable && !empty ? "primary" : "default"}
449
+ testId={`facet-range-${group.slug}-apply`}
450
+ data-analytics="none"
451
+ data-analytics-reason="a filter is a read, not a flow step"
452
+ onClick={commit}
453
+ >
454
+ {t(SEARCH_I18N_KEYS.facetsRangeApply)}
455
+ </GatedButton>
456
+ )}
364
457
  {group.active && (
365
458
  <Button
366
459
  data-testid={`facet-range-${group.slug}-clear`}
367
460
  data-analytics="none"
368
461
  data-analytics-reason="a filter is a read, not a flow step"
369
462
  onClick={() => {
463
+ // The row's own draft goes with the constraint: leaving a
464
+ // pending `2015..` behind would let the panel's Apply put back
465
+ // exactly what this button just removed.
466
+ onDraft?.(group.slug, { range: null, usable: true, dirty: false });
370
467
  props.onApply(group.slug, null);
371
468
  }}
372
469
  >
@@ -240,6 +240,16 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
240
240
  * characteristic, and on a result list that is what a chip is.
241
241
  */
242
242
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
243
+ /**
244
+ * Draw the "Category" pane in the filter panel at all. Default `true`.
245
+ *
246
+ * `false` removes it entirely — see
247
+ * {@link FacetPanelPaneProps.categoryFilter}. The page passes it on and
248
+ * stops counting the category as a reason to keep the filter column open,
249
+ * so a leaf whose only other filter is a price does not get a column
250
+ * containing one pane the surface asked not to draw.
251
+ */
252
+ readonly categoryFilter?: boolean;
243
253
  /**
244
254
  * What the current category is CALLED — the chip's own text. The pair holds
245
255
  * a path of slugs and no way to turn one into a catalogue name; absent, the
@@ -270,7 +280,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
270
280
  *
271
281
  * Defaulted PER LAYOUT rather than left to the panel's own default, because
272
282
  * the two frames want opposite shapes and only this component knows which
273
- * one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
283
+ * one it is drawing: the desktop rail gets `"field"` (a select-style "Any"
274
284
  * that opens the searchable list — a 418-value vocabulary held open in a
275
285
  * 280px column is the whole column), the phone sheet gets `"sheet"` — the
276
286
  * same trigger row, opening a nested picker with a search box, a
@@ -340,6 +350,23 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
340
350
  * describes the whole page, not the results column of it.
341
351
  */
342
352
  readonly resultsHeader?: ReactNode;
353
+ /**
354
+ * The top of the RESULTS COLUMN, above the toolbar and the heading row.
355
+ *
356
+ * The fifth slot, and the four before it were each checked first:
357
+ * `resultsHeader` spans the whole page (both columns on a desktop), which is
358
+ * right for a location row and wrong for anything that belongs over the list
359
+ * — a category's own description, a promoted band, a "12 new since
360
+ * yesterday" line all describe the RESULTS and would otherwise sit over the
361
+ * filter rail as well; `breadcrumb` is a walk up the category tree and
362
+ * renders above the heading; `resultsHeading` is the caption itself; the
363
+ * pane's `toolbar` is the row of controls this slot sits above.
364
+ *
365
+ * Rendered inside the column in BOTH layouts, so on a phone it is above the
366
+ * sort row and on a desktop it is above the sort row of the results column
367
+ * only.
368
+ */
369
+ readonly resultsLead?: ReactNode;
343
370
  /**
344
371
  * Draw the APPLIED filter row in the results header — one chip per applied
345
372
  * value and per applied range, each of which removes it
@@ -459,6 +486,8 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
459
486
 
460
487
  interface SearchPageBodyProps {
461
488
  readonly renderCard?: SearchCardRenderer;
489
+ readonly categoryFilter?: boolean;
490
+ readonly resultsLead?: ReactNode;
462
491
  readonly dictionaryMode?: "field" | "inline" | "sheet";
463
492
  readonly visibleGroups?: number | null;
464
493
  readonly categoryFeatures?: readonly FeatureDef[];
@@ -568,9 +597,13 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
568
597
  // show.
569
598
  facets.activeFilters === 0 &&
570
599
  ranges.length === 0 &&
571
- state.category === undefined &&
600
+ // A category pane the surface turned off is not a control on this rail,
601
+ // however narrowed the search is: the way back out of a leaf is then the
602
+ // breadcrumb or the tiles above, not a filter (`categoryFilter`).
603
+ (props.categoryFilter === false ||
604
+ (state.category === undefined &&
605
+ props.renderCategoryFilter === undefined)) &&
572
606
  state.lang === undefined &&
573
- props.renderCategoryFilter === undefined &&
574
607
  (props.languages ?? []).length === 0;
575
608
  const showFilters = filtersHeader !== undefined || !filtersEmpty;
576
609
 
@@ -616,6 +649,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
616
649
  {...(props.renderCategoryFilter !== undefined
617
650
  ? { renderCategoryFilter: props.renderCategoryFilter }
618
651
  : {})}
652
+ {...(props.categoryFilter !== undefined
653
+ ? { categoryFilter: props.categoryFilter }
654
+ : {})}
619
655
  {...(props.skippedNotice !== undefined
620
656
  ? { skippedNotice: props.skippedNotice }
621
657
  : {})}
@@ -661,6 +697,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
661
697
  const results = (
662
698
  <SearchResultsPane
663
699
  toolbar={toolbar}
700
+ {...(props.resultsLead !== undefined ? { lead: props.resultsLead } : {})}
664
701
  {...(phoneToolbar ? { header: "compact" as const } : {})}
665
702
  headingLevel={props.resultsHeadingLevel ?? 1}
666
703
  {...(view.render !== undefined ? { renderResults: view.render } : {})}
@@ -776,7 +813,8 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
776
813
  /* The catalogue picker becomes the row's leading chip. The panel
777
814
  behind the circle keeps its own copy of the control; both write
778
815
  the same `category` parameter, so they cannot disagree. */
779
- {...(props.renderCategoryFilter !== undefined
816
+ {...(props.renderCategoryFilter !== undefined &&
817
+ props.categoryFilter !== false
780
818
  ? { renderCategoryFilter: props.renderCategoryFilter }
781
819
  : {})}
782
820
  {...(props.categoryLabel !== undefined
@@ -853,6 +891,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
853
891
  footer,
854
892
  filtersHeader,
855
893
  resultsHeader,
894
+ resultsLead,
895
+ categoryFilter,
856
896
  appliedChips,
857
897
  otherCategories,
858
898
  categoryName,
@@ -895,6 +935,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
895
935
  {...(footer !== undefined ? { footer } : {})}
896
936
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
897
937
  {...(resultsHeader !== undefined ? { resultsHeader } : {})}
938
+ {...(resultsLead !== undefined ? { resultsLead } : {})}
939
+ {...(categoryFilter !== undefined ? { categoryFilter } : {})}
898
940
  {...(appliedChips !== undefined ? { appliedChips } : {})}
899
941
  {...(otherCategories !== undefined ? { otherCategories } : {})}
900
942
  {...(categoryName !== undefined ? { categoryName } : {})}
@@ -180,6 +180,16 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
180
180
  * toolbar in here instead of printing a second caption above the pane.
181
181
  */
182
182
  readonly toolbar?: ReactNode;
183
+ /**
184
+ * The very top of the pane, ABOVE the heading row and its toolbar.
185
+ *
186
+ * For whatever describes THIS LIST and is not one of its controls: a
187
+ * category's own introduction, a promoted band, a "12 new since yesterday"
188
+ * line. It is inside the results column, which is the difference between
189
+ * this and `<SearchPage resultsHeader>` — that one spans both columns and
190
+ * would put the same block over the filter rail as well.
191
+ */
192
+ readonly lead?: ReactNode;
183
193
  /**
184
194
  * What this list is CALLED on this surface. Default: "Results".
185
195
  *
@@ -363,6 +373,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
363
373
  <SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
364
374
  {(bag) => (
365
375
  <Flex vertical gap={spacing[4]}>
376
+ {props.lead !== undefined && (
377
+ <div data-testid="search-results-lead">{props.lead}</div>
378
+ )}
366
379
  {props.header === "compact" ? (
367
380
  <Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
368
381
  <Typography.Title
@@ -52,17 +52,17 @@ export interface SortSelectProps {
52
52
  * - the {@link SORT_SELECT_MIN_WIDTH} floor goes, so the control shares one
53
53
  * row with whatever the surface puts beside it instead of pushing it to
54
54
  * the next line;
55
- * - the blocked option's REASON moves from a line under the control into
56
- * the option's own label.
55
+ * - the line under the control goes. The blocked option's REASON does not:
56
+ * it is on the option itself at every width now (see `optionsFor`), and
57
+ * what the compact form drops is the second, separate copy of it.
57
58
  *
58
- * That last move is not the reason being dropped. This file exists because
59
- * the reason used to live in a `title=` a phone can never surface, and a
60
- * phone is exactly where "sort by distance" is greyed out most often. On a
61
- * 390px toolbar the reason as a separate row costs a whole band of the
62
- * viewport above the first result so it goes where the person actually
63
- * meets the refusal: on the disabled row of the open list, which a screen
64
- * reader reads out with the option and a thumb reads at the moment of the
65
- * tap. Nothing is hidden; it is closer to the thing it explains.
59
+ * The reason lives on the row because that is where the person meets the
60
+ * refusal a disabled row of the open list, which a screen reader reads out
61
+ * with the option and a thumb reads at the moment of the tap. This file
62
+ * exists because it used to live in a `title=` a phone can never surface,
63
+ * and a phone is exactly where "sort by distance" is greyed out most often;
64
+ * on a 390px toolbar the reason as a separate row would also cost a whole
65
+ * band of the viewport above the first result.
66
66
  */
67
67
  readonly compact?: boolean;
68
68
  }
@@ -94,7 +94,23 @@ export function SortSelect(props: SortSelectProps): ReactElement {
94
94
  ? actionAvailable()
95
95
  : actionBlocked(SORT_DISTANCE_BLOCKED);
96
96
 
97
- const optionsFor = (describedBy?: string): {
97
+ /**
98
+ * The list, with the blocked row carrying its own reason — AT EVERY WIDTH.
99
+ *
100
+ * This used to be the compact arm's alone. The desktop arm relied on
101
+ * `GatedControl`'s sentence beside the closed select, which is where a
102
+ * screen reader meets it (`aria-describedby`) and is NOT where a person
103
+ * meets the refusal: they open the list, find one row greyed out, and the
104
+ * explanation for it is behind the open dropdown, above a control the
105
+ * dropdown is covering. A greyed row with no reason on it is the same defect
106
+ * this file was written to fix, one width up — it was a `title=` then and a
107
+ * line the dropdown hides now.
108
+ *
109
+ * So the row says it, and the sentence beside the control stays: one is the
110
+ * accessible description of the SELECT, the other is the label of the OPTION
111
+ * that is refused, and they are read in different moments.
112
+ */
113
+ const optionsFor = (): {
98
114
  readonly value: string;
99
115
  readonly label: string;
100
116
  readonly disabled: boolean;
@@ -105,13 +121,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
105
121
  const blocked = value === "distance" && !hasCentre;
106
122
  return {
107
123
  value,
108
- // In the compact form the option carries its own reason — see
109
- // `SortSelectProps.compact`. Elsewhere `GatedControl` renders it once,
110
- // beside the control, and repeating it here would say it twice.
111
- label:
112
- blocked && props.compact === true && describedBy === undefined
113
- ? `${label} — ${t(SORT_DISTANCE_BLOCKED)}`
114
- : label,
124
+ label: blocked ? `${label} ${t(SORT_DISTANCE_BLOCKED)}` : label,
115
125
  disabled: blocked,
116
126
  };
117
127
  });
@@ -153,7 +163,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
153
163
  onChange={(next) => {
154
164
  setSort(next);
155
165
  }}
156
- options={optionsFor(bind["aria-describedby"] ?? "")}
166
+ options={optionsFor()}
157
167
  />
158
168
  </Flex>
159
169
  )}
@@ -74,6 +74,7 @@ export {
74
74
  CHIP_BAND_ORDER,
75
75
  CHIP_ROW_CAP,
76
76
  CHIP_ROW_CLASS,
77
+ CHIP_ROW_MIN_HEIGHT,
77
78
  CHIP_ROW_STYLE_HREF,
78
79
  appliedChipTestId,
79
80
  buildAppliedChips,
@@ -103,6 +104,7 @@ export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
103
104
 
104
105
  export {
105
106
  FacetGroupControl,
107
+ facetGroupIsEmptyHeading,
106
108
  facetGroupShape,
107
109
  facetOptionNodes,
108
110
  isDictionaryFacet,
@@ -122,8 +124,13 @@ export type {
122
124
  export {
123
125
  PopularValues,
124
126
  popularOptions,
127
+ popularValuesLadderCss,
128
+ POPULAR_VALUES_CLASS,
125
129
  POPULAR_VALUES_COLUMNS,
130
+ POPULAR_VALUES_LADDER,
126
131
  POPULAR_VALUES_LIMIT,
132
+ POPULAR_VALUES_STYLE_HREF,
133
+ POPULAR_VALUE_COLUMN_WIDTH,
127
134
  } from "./PopularValues.js";
128
135
  export type { PopularValuesProps } from "./PopularValues.js";
129
136
  export { PartitionChips } from "./PartitionChips.js";
@@ -166,7 +173,7 @@ export type {
166
173
  } from "./DegradationNotice.js";
167
174
  export { UrlIssueNotice } from "./UrlIssueNotice.js";
168
175
  export { RangeFilterRow } from "./RangeFilterRow.js";
169
- export type { RangeFilterRowProps } from "./RangeFilterRow.js";
176
+ export type { RangeDraft, RangeFilterRowProps } from "./RangeFilterRow.js";
170
177
 
171
178
  // ── theming ─────────────────────────────────────────────────────────────────
172
179
  export type { ThemeModeProp } from "./types.js";
@@ -127,6 +127,13 @@ export interface FacetPanelBag {
127
127
  */
128
128
  setValues(slug: string, values: readonly string[]): void;
129
129
  setRange(slug: string, range: SearchRange | null): void;
130
+ /**
131
+ * Write several ranges in ONE commit — see
132
+ * {@link SearchStateBag.setRanges}. What the panel's single "Apply"
133
+ * calls: `setRange` twice in a tick applies the second axis and drops the
134
+ * first, because both edits fold into the same starting state.
135
+ */
136
+ setRanges(ranges: Readonly<Record<string, SearchRange | null>>): void;
130
137
  clear(slug: string): void;
131
138
  clearAll(): void;
132
139
  }
@@ -206,8 +213,15 @@ export function useFacetPanel(props: {
206
213
  */
207
214
  resolveFacetLabels?: FacetLabelResolver;
208
215
  } = {}): FacetPanelBag {
209
- const { state: searchState, setFilter, setRange, clearAll, toggleFilter, activeFilters } =
210
- useSearchState();
216
+ const {
217
+ state: searchState,
218
+ setFilter,
219
+ setRange,
220
+ setRanges,
221
+ clearAll,
222
+ toggleFilter,
223
+ activeFilters,
224
+ } = useSearchState();
211
225
  const t = useT();
212
226
  const query = useSearchQuery(
213
227
  searchState,
@@ -285,6 +299,7 @@ export function useFacetPanel(props: {
285
299
  toggle: toggleFilter,
286
300
  setValues: setFilter,
287
301
  setRange,
302
+ setRanges,
288
303
  clear: (slug) => {
289
304
  setFilter(slug, []);
290
305
  },
@@ -168,6 +168,21 @@ export interface SearchStateBag {
168
168
  toggleFilter(slug: string, value: string): void;
169
169
  setFilter(slug: string, values: readonly string[]): void;
170
170
  setRange(slug: string, range: SearchRange | null): void;
171
+ /**
172
+ * Write SEVERAL ranges at once — the bulk half of {@link setRange}, and the
173
+ * same relationship `setFilter` has to `toggleFilter`.
174
+ *
175
+ * Every setter here closes over the state it edits, so two of them in one
176
+ * tick both fold their change into the SAME starting value and the second
177
+ * one wins. That is invisible while one control writes one axis; it is the
178
+ * whole story for a filter panel with one "Apply" over a price row and
179
+ * a mileage row, where two calls would have applied the mileage and quietly
180
+ * dropped the price. This folds them all into one state and commits once —
181
+ * one request, one history entry.
182
+ *
183
+ * `null` for a slug clears it, exactly as it does on {@link setRange}.
184
+ */
185
+ setRanges(ranges: Readonly<Record<string, SearchRange | null>>): void;
171
186
  setGeo(geo: SearchGeo | null): void;
172
187
  setLimit(limit: number | null): void;
173
188
  clearAll(): void;
@@ -506,6 +521,14 @@ export function SearchStateProvider(
506
521
  apply(setFilterValues(state, slug, values), historyOptions("filter")),
507
522
  setRange: (slug, range) =>
508
523
  apply(setRangeValue(state, slug, range), historyOptions("range")),
524
+ setRanges: (ranges) =>
525
+ apply(
526
+ Object.entries(ranges).reduce(
527
+ (next, [slug, range]) => setRangeValue(next, slug, range),
528
+ state
529
+ ),
530
+ historyOptions("range")
531
+ ),
509
532
  setGeo: (geo) => {
510
533
  apply(patchSearchState(state, { geo }), historyOptions("geo"));
511
534
  },
package/src/index.ts CHANGED
@@ -49,16 +49,21 @@ export {
49
49
  SUGGEST_DEGRADED_CATEGORIES,
50
50
  SUGGEST_DEGRADED_ROLLUP,
51
51
  suggestTerms,
52
+ withheldSlugs,
52
53
  } from "./api/types.js";
53
54
  export type {
55
+ FacetAxisKind,
54
56
  FacetCategoryCount,
55
57
  FacetLabels,
56
58
  FacetLabelsMap,
57
59
  FacetMeta,
60
+ FacetRangeAxis,
58
61
  FacetRangeBounds,
59
62
  FacetRangesMap,
60
63
  FacetSelection,
64
+ FacetWithheldAxis,
61
65
  FacetWithheldGroup,
66
+ FacetWithheldReason,
62
67
  RankingResponse,
63
68
  Scorer,
64
69
  SearchDegradation,
@@ -156,6 +161,9 @@ export {
156
161
  } from "./state/ranges.js";
157
162
  export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
158
163
 
164
+ export { orderPanelItems } from "./state/panel.js";
165
+ export type { OrderPanelItemsInput, PanelItem } from "./state/panel.js";
166
+
159
167
  export {
160
168
  SEARCH_BOX_DEBOUNCE_MS,
161
169
  SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
@@ -239,6 +239,19 @@ export interface FacetGroup {
239
239
  * hold. `undefined` means nobody said, not "inline".
240
240
  */
241
241
  readonly vocabulary?: string;
242
+ /**
243
+ * Where this group sits in the ONE sequence the panel draws — the answer's
244
+ * `facet_labels[<slug>].order`, numbered together with the numeric axes'
245
+ * `facet_meta.ranges[<slug>].order` (stapel-search 0.16.0+).
246
+ *
247
+ * The point of the field is that a group and a range are two ways of
248
+ * narrowing one authored feature, so they share a scale: drawn sorted by it,
249
+ * "Price" and "Year" land among the makes and models instead of above and
250
+ * below all of them. `undefined` (an older server, or a group the plan has
251
+ * no place for, which the wire says as `null`) means "no stated position",
252
+ * and `orderPanelItems` sorts those after everything that has one.
253
+ */
254
+ readonly order?: number | undefined;
242
255
  /** The group's heading: the answer's own `label`, else the feature's
243
256
  * display name, else — with a dev warning — the raw slug. */
244
257
  readonly label: string;
@@ -606,6 +619,19 @@ function optionalVocabulary(
606
619
  return vocabulary === undefined ? {} : { vocabulary };
607
620
  }
608
621
 
622
+ /**
623
+ * The group's stated place in the panel, when the answer states one.
624
+ *
625
+ * `null` on the wire is the server's own "the plan has no position for this
626
+ * group", which reads exactly like a server too old to state one: both mean
627
+ * "sort me after everything that does have a place". So both spread to
628
+ * nothing, and `FacetGroup.order` is a number or absent — never a `null` a
629
+ * comparator would have to remember to special-case.
630
+ */
631
+ function optionalOrder(order: number | null | undefined): { order?: number } {
632
+ return typeof order === "number" ? { order } : {};
633
+ }
634
+
609
635
  function resolveVocabulary(
610
636
  input: BuildFacetGroupsInput,
611
637
  feature: FeatureDef | undefined,
@@ -813,6 +839,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
813
839
  return {
814
840
  slug,
815
841
  urlKey: keys.write[slug] ?? slug,
842
+ ...optionalOrder(input.facetLabels?.[slug]?.order),
816
843
  ...optionalVocabulary(resolveVocabulary(input, feature, slug)),
817
844
  ...resolveGroupLabel(input, feature, slug),
818
845
  feature,