@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
package/manifest.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.3.1",
4
+ "version": "0.7.0",
5
5
  "backend": {
6
6
  "module": "stapel-search",
7
- "contract": ">=0.1 <0.2"
7
+ "contract": ">=0.2 <0.3"
8
8
  },
9
9
  "layers": [
10
10
  "api",
@@ -70,6 +70,17 @@
70
70
  "query",
71
71
  "{params}"
72
72
  ]
73
+ },
74
+ "useSuggest": {
75
+ "kind": "query",
76
+ "operation": "suggest",
77
+ "queryKey": [
78
+ "search",
79
+ "suggest",
80
+ "{type}",
81
+ "{q}",
82
+ "{…}"
83
+ ]
73
84
  }
74
85
  },
75
86
  "errors": {
@@ -393,47 +404,207 @@
393
404
  },
394
405
  "demos": [
395
406
  {
396
- "id": "search.facets",
397
- "title": "Facet panel",
398
- "description": "Counts are computed with each facet's own filter removed, so picking one value does not zero its siblings. Labels come from the category schema through @stapel/attributes-react; a slug the server skipped shows 'not counted', never 0.",
399
- "component": "FacetPanel",
407
+ "id": "search.box",
408
+ "title": "Search box",
409
+ "description": "Types into a draft and searches after a pause, so the address bar does not chase the keyboard; the URL still wins whenever it moves on its own, so the box can never show a word the results are not about. Capped at the server's own MAX_QUERY_CHARS, which makes 'query too long' a refusal this control cannot cause.",
410
+ "component": "SearchBox",
400
411
  "tokens": [
401
412
  "surface-raised"
402
413
  ],
403
414
  "variants": [
404
- "default"
415
+ "empty",
416
+ "typed"
405
417
  ],
406
- "source": "demo/FacetPanel.demo.tsx"
418
+ "source": "demo/SearchBox.demo.tsx"
407
419
  },
408
420
  {
409
- "id": "search.ranking",
410
- "title": "Ranking disclosure",
411
- "description": "Which parameters order the results and how much each weighs, straight from the scorer registry. Parameters the configured engine cannot evaluate are listed with their reason rather than filtered out.",
412
- "component": "RankingDisclosure",
421
+ "id": "search.degradation-notice",
422
+ "title": "Degradation notice",
423
+ "description": "What the engine could not do for THIS query, in words: the typo pass it skipped, the facet counts it sampled rather than counted, the scorer it could not evaluate. An unknown degradation still renders — with its raw literal because a build that predates a limitation should say 'the engine reported something we have no wording for', not nothing.",
424
+ "component": "DegradationNotice",
413
425
  "tokens": [
414
426
  "surface-raised"
415
427
  ],
416
428
  "variants": [
417
- "default"
429
+ "banner",
430
+ "inline"
418
431
  ],
419
- "source": "demo/RankingDisclosure.demo.tsx"
432
+ "source": "demo/DegradationNotice.demo.tsx"
420
433
  },
421
434
  {
422
- "id": "search.results",
423
- "title": "Search results",
424
- "description": "One keyset page, rendered through matchList so 'nothing found' can only be said about a search that actually ran. The count is marked as an estimate when the envelope says so, and degraded[] is surfaced rather than swallowed.",
425
- "component": "SearchResults",
435
+ "id": "search.facet-panel-pane",
436
+ "title": "Filter panel",
437
+ "description": "The drill-down facet panel with every count beside its option including the options you have not chosen, which is what makes them drill-down rather than naive — plus the server's honesty flags said in words: 'these counts are approximate' and the slugs it did not count at all, whose options read 'not counted' and never a zero that looks like an answer.",
438
+ "component": "FacetPanelPane",
426
439
  "covers": [
440
+ "FacetPanel"
441
+ ],
442
+ "tokens": [
443
+ "surface-raised"
444
+ ],
445
+ "variants": [
446
+ "open-search",
447
+ "narrowed"
448
+ ],
449
+ "source": "demo/FacetPanelPane.demo.tsx"
450
+ },
451
+ {
452
+ "id": "search.filter-sheet",
453
+ "title": "Filter sheet",
454
+ "description": "The filters as a phone actually meets them: a bottom sheet over the results, with the drill-down counts beside every option, a Clear all for the two constraints the link arrived with, and a footer that commits by saying how many results the current choices lead to rather than a bare 'Apply'.",
455
+ "component": "SearchPage",
456
+ "tokens": [
457
+ "surface-raised"
458
+ ],
459
+ "variants": [
460
+ "open"
461
+ ],
462
+ "source": "demo/FilterSheet.demo.tsx"
463
+ },
464
+ {
465
+ "id": "search.language-select",
466
+ "title": "Language filter",
467
+ "description": "The languages a deployment indexes, each named the way THIS reader would name it. With nothing offered and nothing applied the control does not render at all: a select whose only option is 'any' is a dead control, and the panel is thinner without it.",
468
+ "component": "LanguageSelect",
469
+ "tokens": [
470
+ "surface-raised"
471
+ ],
472
+ "variants": [
473
+ "offered",
474
+ "from-a-link"
475
+ ],
476
+ "source": "demo/LanguageSelect.demo.tsx"
477
+ },
478
+ {
479
+ "id": "search.page",
480
+ "title": "Search page",
481
+ "description": "The composed /s screen: the query box bound to setText, the filter panel (category and location slots, numeric ranges, drill-down facets), the sort and page-size toolbar, and one keyset page of cards with the DSA promoted marking explained in words rather than in a tooltip.",
482
+ "component": "SearchPage",
483
+ "covers": [
484
+ "SearchStateProvider",
427
485
  "SearchProvider",
428
- "SearchStateProvider"
486
+ "SearchResults",
487
+ "FacetPanel"
488
+ ],
489
+ "tokens": [
490
+ "surface-raised",
491
+ "warning-bg"
492
+ ],
493
+ "variants": [
494
+ "desktop",
495
+ "phone",
496
+ "unreadable-link"
497
+ ],
498
+ "source": "demo/SearchPage.demo.tsx"
499
+ },
500
+ {
501
+ "id": "search.page-size-select",
502
+ "title": "Page size control",
503
+ "description": "A short ladder around the server's DEFAULT_PAGE_SIZE. Choosing a size REPLACES the history entry rather than pushing one: a page size is a preference, not a step through the results, and Back should still undo the last filter.",
504
+ "component": "PageSizeSelect",
505
+ "tokens": [
506
+ "surface-raised"
507
+ ],
508
+ "variants": [
509
+ "ladder",
510
+ "off-ladder"
429
511
  ],
512
+ "source": "demo/PageSizeSelect.demo.tsx"
513
+ },
514
+ {
515
+ "id": "search.range-filter-row",
516
+ "title": "Numeric range filter",
517
+ "description": "A range row per numeric feature of the category schema, plus any slug the URL already constrains — because a constraint that is active must always have a control that removes it, even when the schema no longer explains it. Bounds and step come from the schema; the unit is shown in the field's accessible name.",
518
+ "component": "RangeFilterRow",
519
+ "tokens": [
520
+ "surface-raised"
521
+ ],
522
+ "variants": [
523
+ "untouched",
524
+ "applied"
525
+ ],
526
+ "source": "demo/RangeFilterRow.demo.tsx"
527
+ },
528
+ {
529
+ "id": "search.ranking-pane",
530
+ "title": "Ranking disclosure",
531
+ "description": "The statutory ranking page in the default skin: every scorer with its weight, the sorts it applies to, and — for a scorer the deployment's engine cannot evaluate — the reason it is inactive, so the disclosure describes the ranking that actually ran rather than the one that was configured.",
532
+ "component": "RankingDisclosurePane",
533
+ "covers": [
534
+ "RankingDisclosure"
535
+ ],
536
+ "tokens": [
537
+ "surface-raised"
538
+ ],
539
+ "variants": [
540
+ "desktop",
541
+ "phone"
542
+ ],
543
+ "source": "demo/RankingDisclosurePane.demo.tsx"
544
+ },
545
+ {
546
+ "id": "search.result-card",
547
+ "title": "Result card",
548
+ "description": "The default card a doc type gets for free: a 4:3 photo well drawn before the network answers (via @stapel/image, so a dead URL is a named placeholder rather than a torn-page icon), the title, the price with its currency, the location and distance — and the promoted marking painted from a token role rather than an antd preset, with its explanation as ordinary text beneath it.",
549
+ "component": "SearchResultCard",
550
+ "tokens": [
551
+ "warning-bg",
552
+ "warning-on",
553
+ "warning-border",
554
+ "surface-sunken"
555
+ ],
556
+ "variants": [
557
+ "promoted",
558
+ "plain"
559
+ ],
560
+ "source": "demo/SearchResultCard.demo.tsx"
561
+ },
562
+ {
563
+ "id": "search.results-pane",
564
+ "title": "Results pane",
565
+ "description": "One keyset page in the default skin: a counted heading (a capped count reads 'N+', never a flat number the server did not promise), the degradation banner, an auto-fill card grid, and a pager that is ABSENT when there is nothing to page rather than disabled with its reason in a tooltip.",
566
+ "component": "SearchResultsPane",
567
+ "covers": [
568
+ "SearchResults"
569
+ ],
570
+ "tokens": [
571
+ "surface-raised",
572
+ "warning-bg"
573
+ ],
574
+ "variants": [
575
+ "desktop",
576
+ "phone",
577
+ "empty"
578
+ ],
579
+ "source": "demo/SearchResultsPane.demo.tsx"
580
+ },
581
+ {
582
+ "id": "search.sort-select",
583
+ "title": "Sort control",
584
+ "description": "The five shipped sorts, plus whatever unknown sort a shared link already carries — offered rather than silently reset, because resetting it would rewrite the meaning of somebody's link on load and the server is the one entitled to refuse an unknown sort by name.",
585
+ "component": "SortSelect",
586
+ "tokens": [
587
+ "surface-raised"
588
+ ],
589
+ "variants": [
590
+ "no-centre",
591
+ "with-centre"
592
+ ],
593
+ "source": "demo/SortSelect.demo.tsx"
594
+ },
595
+ {
596
+ "id": "search.url-issue-notice",
597
+ "title": "Unreadable link notice",
598
+ "description": "The parameters a shared link carried that the codec could not read, named one by one. The alternative is not 'no notice' but a quietly WIDER search than the one that was shared, which is the difference between a page that is wrong and a page that says which part of the link it lost.",
599
+ "component": "UrlIssueNotice",
430
600
  "tokens": [
431
601
  "surface-raised"
432
602
  ],
433
603
  "variants": [
434
- "default"
604
+ "two-issues",
605
+ "one-issue"
435
606
  ],
436
- "source": "demo/SearchResults.demo.tsx"
607
+ "source": "demo/UrlIssueNotice.demo.tsx"
437
608
  }
438
609
  ],
439
610
  "i18nKeys": [
@@ -491,6 +662,14 @@
491
662
  "error.500.internal",
492
663
  "error.503.mandate_unavailable",
493
664
  "error.503.search_backend_unavailable",
665
+ "search.box.clear",
666
+ "search.box.label",
667
+ "search.box.placeholder",
668
+ "search.box.submit",
669
+ "search.box.suggestions",
670
+ "search.category.clear",
671
+ "search.category.current",
672
+ "search.category.title",
494
673
  "search.degraded.category_rollup",
495
674
  "search.degraded.exact_facet_counts",
496
675
  "search.degraded.exact_total",
@@ -509,15 +688,34 @@
509
688
  "search.facets.loading",
510
689
  "search.facets.not_counted",
511
690
  "search.facets.range_apply",
691
+ "search.facets.range_clear",
512
692
  "search.facets.range_from",
693
+ "search.facets.range_from_aria",
694
+ "search.facets.range_invalid",
513
695
  "search.facets.range_to",
696
+ "search.facets.range_to_aria",
514
697
  "search.facets.skipped",
515
698
  "search.facets.title",
699
+ "search.filters.apply",
700
+ "search.filters.dismiss",
701
+ "search.filters.open",
702
+ "search.filters.show_count",
703
+ "search.filters.show_count.one",
704
+ "search.filters.show_count.other",
705
+ "search.filters.show_count_at_least",
706
+ "search.filters.show_count_at_least.one",
707
+ "search.filters.show_count_at_least.other",
516
708
  "search.geo.box",
517
709
  "search.geo.center",
518
710
  "search.geo.clear",
519
711
  "search.geo.radius_km",
712
+ "search.geo.radius_label",
520
713
  "search.geo.title",
714
+ "search.language.any",
715
+ "search.language.label",
716
+ "search.limit.from_link",
717
+ "search.limit.label",
718
+ "search.limit.option",
521
719
  "search.ranking.applies_to",
522
720
  "search.ranking.empty",
523
721
  "search.ranking.inactive",
@@ -534,11 +732,15 @@
534
732
  "search.results.count_approximate",
535
733
  "search.results.count_approximate.one",
536
734
  "search.results.count_approximate.other",
735
+ "search.results.count_at_least",
736
+ "search.results.count_at_least.one",
737
+ "search.results.count_at_least.other",
537
738
  "search.results.count_exact",
538
739
  "search.results.count_exact.one",
539
740
  "search.results.count_exact.other",
540
741
  "search.results.distance_km",
541
742
  "search.results.empty",
743
+ "search.results.image_alt",
542
744
  "search.results.load_failed",
543
745
  "search.results.loading",
544
746
  "search.results.next",
@@ -568,29 +770,42 @@
568
770
  "runtime": [
569
771
  "FILTER_PREFIX",
570
772
  "FacetPanel",
773
+ "RANGE_FEATURE_TYPES",
571
774
  "RANGE_PREFIX",
572
775
  "RankingDisclosure",
573
776
  "SEARCH_BACKEND_UNAVAILABLE",
777
+ "SEARCH_BOX_DEBOUNCE_MS",
778
+ "SEARCH_BOX_SUGGEST_DEBOUNCE_MS",
779
+ "SEARCH_DEFAULT_PAGE_SIZE",
574
780
  "SEARCH_ERRORS",
575
781
  "SEARCH_ERROR_CODES",
576
782
  "SEARCH_I18N_KEYS",
577
783
  "SEARCH_I18N_PLURAL_KEYS",
784
+ "SEARCH_MAX_PAGE_SIZE",
578
785
  "SEARCH_PARAM",
786
+ "SEARCH_QUERY_MAX_CHARS",
579
787
  "SEARCH_SORTS",
580
788
  "SEARCH_WINDOW_EXCEEDED",
789
+ "SUGGEST_MAX_LIMIT",
790
+ "SUGGEST_MIN_CHARS",
581
791
  "SearchProvider",
582
792
  "SearchResults",
583
793
  "SearchRuntimeContext",
584
794
  "SearchStateProvider",
585
795
  "activeFilterCount",
586
796
  "buildFacetGroups",
797
+ "buildRangeGroups",
587
798
  "clearFilters",
588
799
  "countIsEstimate",
800
+ "countKind",
589
801
  "createSearchApi",
590
802
  "createSearchRuntime",
591
803
  "degradationMessageKey",
592
804
  "explainSearchError",
593
805
  "facetOptionLabel",
806
+ "isCountNuanceOnly",
807
+ "isRangeFeature",
808
+ "isRangeUsable",
594
809
  "navEntries",
595
810
  "ownsParam",
596
811
  "parseDegradations",
@@ -605,16 +820,20 @@
605
820
  "setRangeValue",
606
821
  "toggleFilterValue",
607
822
  "useAppliedSort",
823
+ "useFacetPanel",
608
824
  "useRankingDisclosure",
609
825
  "useSearchAnalytics",
610
826
  "useSearchApi",
827
+ "useSearchBox",
611
828
  "useSearchQuery",
612
829
  "useSearchRuntime",
613
830
  "useSearchState",
831
+ "useSuggest",
614
832
  "writeSearchState"
615
833
  ],
616
834
  "types": [
617
835
  "BuildFacetGroupsInput",
836
+ "BuildRangeGroupsInput",
618
837
  "CreateSearchRuntimeOptions",
619
838
  "FacetGroup",
620
839
  "FacetMeta",
@@ -623,12 +842,15 @@
623
842
  "FacetSelection",
624
843
  "ParseSearchStateOptions",
625
844
  "ParsedSearchState",
845
+ "RangeGroup",
626
846
  "RankingDisclosureBag",
627
847
  "RankingResponse",
628
848
  "Remediation",
629
849
  "Schemas",
630
850
  "Scorer",
631
851
  "SearchApi",
852
+ "SearchBoxBag",
853
+ "SearchCountKind",
632
854
  "SearchDegradation",
633
855
  "SearchDegradationKind",
634
856
  "SearchErrorCode",
@@ -652,7 +874,8 @@
652
874
  "SearchStatePatch",
653
875
  "SearchStateProviderProps",
654
876
  "SuggestParams",
655
- "SuggestResponse"
877
+ "SuggestResponse",
878
+ "UseSearchBoxOptions"
656
879
  ]
657
880
  }
658
881
  }
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/search-react",
3
- "version": "0.3.1",
3
+ "version": "0.7.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.3.1",
3
+ "version": "0.7.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, results, ranking) must stay out of the main bundle",
61
61
  "path": "dist/default/index.js",
62
- "limit": "13 KB"
62
+ "limit": "16 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/core": "^0.17.0",
116
- "@stapel/showcase": "^0.2.0",
117
- "@stapel/tokens": "^0.5.0",
118
- "@stapel/tokens-antd": "^0.5.0"
118
+ "@stapel/image": "^0.4.1",
119
+ "@stapel/showcase": "^0.3.0",
120
+ "@stapel/core": "^0.18.1",
121
+ "@stapel/tokens": "^0.5.1",
122
+ "@stapel/attributes-react": "^0.3.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.3.1",
4
+ "version": "0.7.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -213,7 +213,11 @@ export interface components {
213
213
  prev_anchor: string | null;
214
214
  has_next: boolean;
215
215
  has_prev: boolean;
216
- count: number;
216
+ /** @description How many documents match. NEVER 0 beside a non-empty items[] — the answer may not claim fewer matches than the page shows. `null` means the engine cannot say, and is rendered as no count at all. */
217
+ count: number | null;
218
+ /** @description True when `count` is a floor: at least this many match, possibly more (a capped count, a window-truncated engine answer). Render 'N+', never 'N'. */
219
+ count_is_lower_bound: boolean;
220
+ /** @description True when `count` is exact for THIS answer — equivalent to `count is not null and not count_is_lower_bound`. Per answer, not per engine: an engine without a guaranteed exact total still counts a small candidate set exactly. */
217
221
  exact_total: boolean;
218
222
  /** @description What the configured engine could not do for this query. */
219
223
  degraded: string[];
@@ -9,41 +9,111 @@
9
9
  * An `unknown` degradation still renders — with the raw literal, because a
10
10
  * build that predates a new limitation should say "the engine reported
11
11
  * something we have no wording for: X", not nothing at all.
12
+ *
13
+ * ── Two deliberate ways NOT to shout ───────────────────────────────────────
14
+ *
15
+ * 1. **`exact_total` alone never raises a banner.** It is a count NUANCE, not
16
+ * a failed search: the rows are right, and the single consequence — that
17
+ * the total is a floor — is already spoken by the count as "N+". A warning
18
+ * box over a perfectly good result page teaches the reader that the page
19
+ * is broken, and a banner that cries wolf on every landing page is a
20
+ * banner nobody reads on the day `category_rollup` appears in it. Beside
21
+ * any other degradation it renders normally, because the list is then
22
+ * describing an answer that really is degraded. See
23
+ * {@link isCountNuanceOnly}.
24
+ * 2. **`variant`.** A catalogue page wants the banner; a landing page that
25
+ * shows six cards under a hero has no room for a warning box and passes
26
+ * `"inline"` (one quiet line) or `"off"`. `"off"` is a decision the
27
+ * CONTAINER makes about ITS surface — the notice stays the default, and
28
+ * nothing here silences a degradation for everyone.
12
29
  */
13
30
  import type { ReactElement } from "react";
14
- import { Alert, Typography } from "antd";
31
+ import { Alert, Flex, Typography } from "antd";
32
+ import { fontSize, spacing } from "@stapel/tokens";
15
33
  import { useT } from "@stapel/core";
16
34
  import type { SearchDegradation } from "../api/types.js";
35
+ import { isCountNuanceOnly } from "../state/degradations.js";
17
36
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
18
37
 
38
+ /**
39
+ * How loudly a surface says what the engine could not do.
40
+ *
41
+ * - `"banner"` (default) — an antd warning `Alert` with one line per item.
42
+ * - `"inline"` — the same sentences as quiet secondary text, no box.
43
+ * - `"off"` — nothing. For a surface where the notice does not belong at all;
44
+ * the container that turns it off owns saying so somewhere else.
45
+ */
46
+ export type DegradationNoticeVariant = "banner" | "inline" | "off";
47
+
19
48
  export interface DegradationNoticeProps {
20
49
  readonly degradations: readonly SearchDegradation[];
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;
21
58
  }
22
59
 
23
60
  export function DegradationNotice(
24
61
  props: DegradationNoticeProps
25
62
  ): ReactElement | null {
26
63
  const t = useT();
64
+ const variant: DegradationNoticeVariant = props.variant ?? "banner";
65
+ if (variant === "off") return null;
27
66
  if (props.degradations.length === 0) return null;
67
+ // A count nuance is not a degraded search — the count already says "N+".
68
+ if (isCountNuanceOnly(props.degradations)) return null;
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.
85
+ const lines = props.degradations.map((degradation) => (
86
+ <li key={degradation.raw} data-degradation={degradation.raw}>
87
+ <Typography.Text>{say(degradation)}</Typography.Text>
88
+ </li>
89
+ ));
90
+
91
+ if (variant === "inline") {
92
+ return (
93
+ <Flex vertical gap={spacing[1]} data-testid="search-degraded" data-variant="inline">
94
+ <Typography.Text type="secondary" style={{ fontSize: fontSize.xs.fontSize }}>
95
+ {t(SEARCH_I18N_KEYS.degradedTitle)}
96
+ </Typography.Text>
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
+ ))}
103
+ </ul>
104
+ </Flex>
105
+ );
106
+ }
107
+
28
108
  return (
29
109
  <Alert
30
110
  type="warning"
31
111
  showIcon
32
112
  data-testid="search-degraded"
33
- message={t(SEARCH_I18N_KEYS.degradedTitle)}
113
+ data-variant="banner"
114
+ title={t(SEARCH_I18N_KEYS.degradedTitle)}
34
115
  description={
35
- <ul style={{ margin: 0, paddingInlineStart: 20 }}>
36
- {props.degradations.map((degradation) => (
37
- <li key={degradation.raw} data-degradation={degradation.raw}>
38
- <Typography.Text type="secondary">
39
- {t(degradation.messageKey, {
40
- scorer: degradation.scorer ?? "",
41
- raw: degradation.raw,
42
- })}
43
- </Typography.Text>
44
- </li>
45
- ))}
46
- </ul>
116
+ <ul style={{ margin: 0, paddingInlineStart: spacing[5] }}>{lines}</ul>
47
117
  }
48
118
  />
49
119
  );