@stapel/search-react 0.23.0 → 0.25.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 (55) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +76 -2
  3. package/dist/default/FilterChips.d.ts +86 -2
  4. package/dist/default/FilterChips.d.ts.map +1 -1
  5. package/dist/default/FilterChips.js +208 -2
  6. package/dist/default/FilterChips.js.map +1 -1
  7. package/dist/default/OtherCategoriesLine.d.ts +49 -0
  8. package/dist/default/OtherCategoriesLine.d.ts.map +1 -0
  9. package/dist/default/OtherCategoriesLine.js +127 -0
  10. package/dist/default/OtherCategoriesLine.js.map +1 -0
  11. package/dist/default/SearchPage.d.ts +36 -1
  12. package/dist/default/SearchPage.d.ts.map +1 -1
  13. package/dist/default/SearchPage.js +9 -4
  14. package/dist/default/SearchPage.js.map +1 -1
  15. package/dist/default/SearchResultsPane.d.ts +15 -0
  16. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  17. package/dist/default/SearchResultsPane.js +4 -1
  18. package/dist/default/SearchResultsPane.js.map +1 -1
  19. package/dist/default/index.d.ts +4 -2
  20. package/dist/default/index.d.ts.map +1 -1
  21. package/dist/default/index.js +2 -1
  22. package/dist/default/index.js.map +1 -1
  23. package/dist/headless/useOtherCategories.d.ts +52 -0
  24. package/dist/headless/useOtherCategories.d.ts.map +1 -0
  25. package/dist/headless/useOtherCategories.js +92 -0
  26. package/dist/headless/useOtherCategories.js.map +1 -0
  27. package/dist/i18n/es.d.ts.map +1 -1
  28. package/dist/i18n/es.js +9 -0
  29. package/dist/i18n/es.js.map +1 -1
  30. package/dist/i18n/keys.d.ts +42 -0
  31. package/dist/i18n/keys.d.ts.map +1 -1
  32. package/dist/i18n/keys.js +53 -0
  33. package/dist/i18n/keys.js.map +1 -1
  34. package/dist/i18n/ru.d.ts.map +1 -1
  35. package/dist/i18n/ru.js +10 -0
  36. package/dist/i18n/ru.js.map +1 -1
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/llms.txt +2 -1
  42. package/manifest.json +34 -1
  43. package/nav-manifest.json +1 -1
  44. package/package.json +7 -7
  45. package/src/analytics/generated/events.json +1 -1
  46. package/src/default/FilterChips.tsx +308 -4
  47. package/src/default/OtherCategoriesLine.tsx +238 -0
  48. package/src/default/SearchPage.tsx +65 -1
  49. package/src/default/SearchResultsPane.tsx +28 -0
  50. package/src/default/index.ts +28 -1
  51. package/src/headless/useOtherCategories.ts +193 -0
  52. package/src/i18n/es.ts +10 -0
  53. package/src/i18n/keys.ts +57 -0
  54. package/src/i18n/ru.ts +11 -0
  55. package/src/index.ts +10 -0
@@ -1,6 +1,42 @@
1
1
  /**
2
- * `<FilterChips>` — the phone's filter row: one horizontally scrolling line of
3
- * chips, each of which opens its OWN picker.
2
+ * `<FilterChips>` — the filter row, in its two modes.
3
+ *
4
+ * ## `mode="openers"` (the default): the phone's row
5
+ *
6
+ * One horizontally scrolling line of chips, each of which opens its OWN
7
+ * picker. Everything below this heading describes that mode.
8
+ *
9
+ * ## `mode="applied"`: the row a rail on screen needs
10
+ *
11
+ * Where the panel is already drawn — the desktop column — a row of openers is
12
+ * the wrong shape twice over: it prints the whole rail a second time, and no
13
+ * chip in it REMOVES anything without opening a modal over the results. A
14
+ * storefront hit exactly that (a 2377px filter column; picking two values left
15
+ * nothing between the header and the first card, and dropping one of them
16
+ * meant scrolling the rail until its button came back) and wrote its own row.
17
+ * This mode is that row, in the pair:
18
+ *
19
+ * - one chip per applied facet VALUE and per applied numeric range, never per
20
+ * axis — "Brand" with three values chosen is three chips, three removals;
21
+ * - each chip names the axis AND the value ("Brand: Bosch", "Price: from 100
22
+ * to 500"), because beside a dozen axes a bare value names nothing;
23
+ * - each chip is a real `<button>` whose press removes THAT constraint, and
24
+ * whose accessible name says so — not an antd `Tag closable`, whose close
25
+ * icon is a `<span>` with no tab stop, i.e. a constraint a keyboard can
26
+ * read and cannot drop;
27
+ * - the same clear-all the rail's footer runs, beside the chips instead of a
28
+ * column-height away;
29
+ * - nothing applied, nothing drawn. An empty band above the results is
30
+ * furniture, and this row's whole claim is that it is there only when it
31
+ * has something to say.
32
+ *
33
+ * Both modes read the SAME bag (`useFacetPanel`, `buildRangeGroups` over the
34
+ * page's own state) the rail reads, so no two surfaces can disagree about what
35
+ * is applied or about what a value is called — including the label path:
36
+ * the answer's `facet_labels` first, the category schema second, the raw slug
37
+ * marked as such third. Every applied chip stamps `data-label-source` and
38
+ * `data-value-label-source` so a storefront's test fails on a raw index term
39
+ * rather than eyeballing one.
4
40
  *
5
41
  * ## What it replaces, and why the replacement is not cosmetic
6
42
  *
@@ -137,7 +173,7 @@ import {
137
173
  compareFacetsByEvidence,
138
174
  facetGroupIsDrawable,
139
175
  } from "../state/facets.js";
140
- import type { FacetGroup } from "../state/facets.js";
176
+ import type { FacetGroup, FacetLabelSource } from "../state/facets.js";
141
177
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
142
178
  import { FacetGroupControl } from "./FacetGroupControl.js";
143
179
  import { RangeFilterRow } from "./RangeFilterRow.js";
@@ -290,7 +326,11 @@ export function capChipRow(
290
326
  return { visible: specs.slice(0, cut), overflow: specs.length - cut };
291
327
  }
292
328
 
293
- export interface FilterChipsProps {
329
+ /** What the row is FOR — see this module's opening note. */
330
+ export type FilterChipsMode = "openers" | "applied";
331
+
332
+ /** What both modes read the search through. */
333
+ export interface FilterChipsCommonProps {
294
334
  /** The category's feature schema — the source of option labels, of which
295
335
  * slugs get a range chip, of which slugs are choosable at all, and of how
296
336
  * each group is drawn. */
@@ -302,6 +342,11 @@ export interface FilterChipsProps {
302
342
  * value cannot read one way on a chip and another way inside the sheet.
303
343
  */
304
344
  readonly resolveFacetLabels?: FacetLabelResolver;
345
+ }
346
+
347
+ export interface FilterChipsOpenerProps extends FilterChipsCommonProps {
348
+ /** The default. Every chip OPENS a picker; see the module note. */
349
+ readonly mode?: "openers";
305
350
  /**
306
351
  * The catalogue picker (`categories-react`), same slot the panel takes.
307
352
  *
@@ -335,6 +380,20 @@ export interface FilterChipsProps {
335
380
  readonly maxRowChips?: number | null;
336
381
  }
337
382
 
383
+ /**
384
+ * The applied row: one chip per constraint, each of which removes it.
385
+ *
386
+ * None of the opener props are here and that is the point — a row that draws
387
+ * only what is applied has no picker to open, no capped tail to stand in front
388
+ * of and no category chip (narrowing the category is a navigation, not a
389
+ * filter this row can drop).
390
+ */
391
+ export interface FilterChipsAppliedProps extends FilterChipsCommonProps {
392
+ readonly mode: "applied";
393
+ }
394
+
395
+ export type FilterChipsProps = FilterChipsOpenerProps | FilterChipsAppliedProps;
396
+
338
397
  /**
339
398
  * The label a chip carries: the group's name alone when nothing is chosen,
340
399
  * and the CHOICE when something is — "Brand" becomes "Bosch", "Brand, +2".
@@ -373,7 +432,22 @@ export function categoryLeaf(path: string): string | undefined {
373
432
  return /^\d+$/.test(leaf) ? undefined : leaf;
374
433
  }
375
434
 
435
+ /**
436
+ * The row, in whichever mode was asked for.
437
+ *
438
+ * Two components rather than one with a branch in it: the modes share the
439
+ * search they read and share nothing they DRAW, and a single body would have
440
+ * to run both sets of hooks on every render of either.
441
+ */
376
442
  export function FilterChips(props: FilterChipsProps): ReactElement | null {
443
+ return props.mode === "applied" ? (
444
+ <AppliedChipRow {...props} />
445
+ ) : (
446
+ <OpenerChipRow {...props} />
447
+ );
448
+ }
449
+
450
+ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
377
451
  const t = useT();
378
452
  const { state, setCategory } = useSearchState();
379
453
  const bag = useFacetPanel({
@@ -644,6 +718,236 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
644
718
  );
645
719
  }
646
720
 
721
+ /* ── the applied row ────────────────────────────────────────────────────── */
722
+
723
+ type Translate = (key: string, params?: Record<string, unknown>) => string;
724
+
725
+ /** Which constraint one applied chip drops. */
726
+ export type AppliedChipTarget =
727
+ | { readonly kind: "facet"; readonly slug: string; readonly value: string }
728
+ | { readonly kind: "range"; readonly slug: string };
729
+
730
+ /** One applied constraint, resolved to the words it prints and what it drops. */
731
+ export interface AppliedChip {
732
+ readonly key: string;
733
+ readonly target: AppliedChipTarget;
734
+ /** What the AXIS is called, and which source named it. */
735
+ readonly name: string;
736
+ readonly nameSource: FacetLabelSource;
737
+ /** What the VALUE is called, and which source named it. */
738
+ readonly value: string;
739
+ readonly valueSource: FacetLabelSource;
740
+ }
741
+
742
+ /** The chip's own test hook — the constraint it drops, spelled out. */
743
+ export function appliedChipTestId(target: AppliedChipTarget): string {
744
+ return target.kind === "facet"
745
+ ? `search-applied-chip-${target.slug}-${target.value}`
746
+ : `search-applied-chip-range-${target.slug}`;
747
+ }
748
+
749
+ /**
750
+ * A numeric constraint as one phrase.
751
+ *
752
+ * The bounds print exactly as the URL carries them — the wire never promised a
753
+ * number and reformatting one would rewrite the link — except on a core MONEY
754
+ * axis, where the currency the answer's own cards carry turns "1000" into
755
+ * money, the same way the cards do.
756
+ */
757
+ export function rangeChipText(range: RangeGroup, t: Translate): string {
758
+ const one = (bound: string): string => {
759
+ const n = Number(bound);
760
+ if (range.currency !== undefined && Number.isFinite(n)) {
761
+ return new Intl.NumberFormat(undefined, {
762
+ style: "currency",
763
+ currency: range.currency,
764
+ maximumFractionDigits: 0,
765
+ }).format(n);
766
+ }
767
+ return range.unit === undefined ? bound : `${bound} ${range.unit}`;
768
+ };
769
+ if (range.from !== undefined && range.to !== undefined) {
770
+ return t(SEARCH_I18N_KEYS.filtersChipRangeBetween, {
771
+ from: one(range.from),
772
+ to: one(range.to),
773
+ });
774
+ }
775
+ if (range.from !== undefined) {
776
+ return t(SEARCH_I18N_KEYS.filtersChipRangeFrom, { from: one(range.from) });
777
+ }
778
+ return t(SEARCH_I18N_KEYS.filtersChipRangeTo, { to: one(range.to ?? "") });
779
+ }
780
+
781
+ /**
782
+ * Where a range axis got its NAME — the same three-source question the facet
783
+ * groups answer for themselves, asked of the other half of the model.
784
+ *
785
+ * A core axis is named by the answer declaring it (`facet_meta.core_ranges`)
786
+ * and this package's own catalogue; a category attribute is named by the
787
+ * schema; a slug the URL constrains that neither explains prints as itself,
788
+ * and that is `"none"` — the same honest bottom the rail marks.
789
+ */
790
+ export function rangeLabelSource(range: RangeGroup): FacetLabelSource {
791
+ if (range.core) return "server";
792
+ return range.feature === undefined ? "none" : "schema";
793
+ }
794
+
795
+ /**
796
+ * Every applied constraint of the current search, in the order the rail states
797
+ * them: the facet values first, in the order the groups came in, then the
798
+ * numeric ranges.
799
+ *
800
+ * Pure, and exported, because "one chip per VALUE" is the whole shape of the
801
+ * row — a build that collapsed three chosen brands into one chip would remove
802
+ * three filters with one press and no test that only counts chips would see
803
+ * it.
804
+ */
805
+ export function buildAppliedChips(input: {
806
+ readonly groups: readonly FacetGroup[];
807
+ readonly ranges: readonly RangeGroup[];
808
+ readonly t: Translate;
809
+ }): readonly AppliedChip[] {
810
+ const chips: AppliedChip[] = [];
811
+ for (const group of input.groups) {
812
+ for (const option of group.options) {
813
+ if (!option.selected) continue;
814
+ chips.push({
815
+ key: `f.${group.slug}=${option.value}`,
816
+ target: { kind: "facet", slug: group.slug, value: option.value },
817
+ name: group.label,
818
+ nameSource: group.labelSource,
819
+ value: option.label,
820
+ valueSource: option.labelSource,
821
+ });
822
+ }
823
+ }
824
+ for (const range of input.ranges) {
825
+ if (!range.active) continue;
826
+ const source = rangeLabelSource(range);
827
+ chips.push({
828
+ key: `r.${range.slug}`,
829
+ target: { kind: "range", slug: range.slug },
830
+ name: range.label,
831
+ nameSource: source,
832
+ value: rangeChipText(range, input.t),
833
+ // The bounds are the person's own numbers — nobody had to name them.
834
+ valueSource: source,
835
+ });
836
+ }
837
+ return chips;
838
+ }
839
+
840
+ /** The applied row wraps: it stands beside a rail, not on a 390px scroller,
841
+ * and a constraint pushed off the line is a constraint with no control. */
842
+ const APPLIED_ROW: CSSProperties = {
843
+ display: "flex",
844
+ alignItems: "center",
845
+ gap: spacing[2],
846
+ flexWrap: "wrap",
847
+ // Room for the focus ring of the first chip, which a flush edge clips.
848
+ paddingBlock: spacing[1],
849
+ };
850
+
851
+ function AppliedChipRow(props: FilterChipsAppliedProps): ReactElement | null {
852
+ const t = useT();
853
+ const { state } = useSearchState();
854
+ const bag = useFacetPanel({
855
+ ...(props.categoryFeatures !== undefined
856
+ ? { categoryFeatures: props.categoryFeatures }
857
+ : {}),
858
+ ...(props.locale !== undefined ? { locale: props.locale } : {}),
859
+ ...(props.resolveFacetLabels !== undefined
860
+ ? { resolveFacetLabels: props.resolveFacetLabels }
861
+ : {}),
862
+ });
863
+ const ranges = buildRangeGroups({
864
+ state,
865
+ ...(props.categoryFeatures !== undefined
866
+ ? { categoryFeatures: props.categoryFeatures }
867
+ : {}),
868
+ coreRanges: bag.coreRanges,
869
+ ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
870
+ t,
871
+ });
872
+
873
+ /* NOTHING IS DRAWN UNTIL THE ANSWER LANDS.
874
+ *
875
+ * Both halves of a chip are named by the envelope: the heading path
876
+ * (`facet_labels[<slug>]`), and — for a range — the answer's own declaration
877
+ * of which axes are CORE columns (`facet_meta.core_ranges`). Before it, an
878
+ * `r.price` the URL carries is a slug this row has no name for, so it would
879
+ * draw a chip captioned with that slug and rename it a moment later. A
880
+ * caption that changes under the reader is worse than a caption that
881
+ * arrives; the panel is the control in the meantime.
882
+ */
883
+ const answered = bag.state.status === "ready";
884
+ const chips = buildAppliedChips({
885
+ groups: answered ? bag.state.data : [],
886
+ ranges: answered ? ranges : [],
887
+ t,
888
+ });
889
+ if (chips.length === 0) return null;
890
+
891
+ const remove = (target: AppliedChipTarget): void => {
892
+ if (target.kind === "facet") bag.toggle(target.slug, target.value);
893
+ else bag.setRange(target.slug, null);
894
+ };
895
+
896
+ return (
897
+ <div
898
+ style={APPLIED_ROW}
899
+ role="group"
900
+ aria-label={t(SEARCH_I18N_KEYS.filtersAppliedLabel)}
901
+ data-testid="search-applied-chips"
902
+ >
903
+ {chips.map((chip) => {
904
+ const text = t(SEARCH_I18N_KEYS.filtersChipPair, {
905
+ name: chip.name,
906
+ value: chip.value,
907
+ });
908
+ return (
909
+ <Button
910
+ key={chip.key}
911
+ shape="round"
912
+ size="small"
913
+ style={CHIP}
914
+ data-testid={appliedChipTestId(chip.target)}
915
+ /* The two halves of the caption, marked with what named each of
916
+ them. `[data-label-source="none"]` is the assertion that a raw
917
+ index term ever reached this row. */
918
+ data-label-source={chip.nameSource}
919
+ data-value-label-source={chip.valueSource}
920
+ data-analytics="none"
921
+ data-analytics-reason="dropping a filter is a read, not a flow step"
922
+ aria-label={t(SEARCH_I18N_KEYS.filtersChipRemove, { filter: text })}
923
+ onClick={() => {
924
+ remove(chip.target);
925
+ }}
926
+ >
927
+ <span>{text}</span>
928
+ <span aria-hidden="true">{"×"}</span>
929
+ </Button>
930
+ );
931
+ })}
932
+ {/* The SAME action the rail's own footer runs, beside the chips instead
933
+ of a column-height down the page. */}
934
+ <Button
935
+ type="link"
936
+ size="small"
937
+ style={{ paddingInline: 0 }}
938
+ data-testid="search-applied-chips-clear"
939
+ data-analytics="none"
940
+ data-analytics-reason="dropping the filters is a read, not a flow step"
941
+ onClick={() => {
942
+ bag.clearAll();
943
+ }}
944
+ >
945
+ {t(SEARCH_I18N_KEYS.facetsClearAll, { count: bag.activeFilters })}
946
+ </Button>
947
+ </div>
948
+ );
949
+ }
950
+
647
951
  /** The mark on the "all filters" chip: something is applied. Not a count —
648
952
  * the counts are on the chips beside it, and a number inside a 32px circle is
649
953
  * a number nobody reads. */
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `<OtherCategoriesLine>` — "Search in other categories: Cars 12 · Buses 3 ·
3
+ * Motorhomes 1 · …", on ONE line, drawn from the answer that drew the cards.
4
+ *
5
+ * ## What it replaces
6
+ *
7
+ * A full-width block under the results with one row per category, fetched
8
+ * separately and arriving after the page had settled. Two defects in one
9
+ * control:
10
+ *
11
+ * - **it was tall.** Fourteen sections became fourteen rows — a screen and a
12
+ * half of navigation under a list of listings, for information that fits
13
+ * in a sentence.
14
+ * - **it was late.** A second request meant the block appeared a beat after
15
+ * the cards and PUSHED them, on the one screen where a person has already
16
+ * started reading. The information was not even new: `/query` had already
17
+ * answered with `facet_meta.categories`, and the type-ahead had shown the
18
+ * same sections a keystroke earlier.
19
+ *
20
+ * This line renders in the SAME frame as the results, out of the same
21
+ * response ({@link useOtherCategories}), so there is nothing to arrive late.
22
+ * The single case that still needs a request — an empty result set, whose
23
+ * candidate list is empty by definition — draws into a slot whose height is
24
+ * reserved from the first frame, so the answer lands without moving anything.
25
+ *
26
+ * ## Pressing an entry narrows the search; it does not leave it
27
+ *
28
+ * The count beside a name is the count for THIS QUERY in that section — the
29
+ * server's `facet_meta.categories`. A link to the bare category feed would
30
+ * show a different, larger number, so the caption would be a lie one click
31
+ * later. Each entry therefore writes the `category` parameter of the search
32
+ * already on screen, keeping the query: press "Cars 12" and twelve results
33
+ * follow. That is a state change, so each entry is a real `<button>` and not
34
+ * an anchor.
35
+ *
36
+ * ## Two rows on a phone, at most
37
+ *
38
+ * The cap is halved on the sheet surface ({@link OTHER_CATEGORIES_PHONE_LIMIT}),
39
+ * and the collapsed line is clamped to two rows besides — a cap counts
40
+ * entries, and it is name LENGTH that turns a line into a paragraph. Expanding
41
+ * is the person's own press, and an expanded line is allowed to be as tall as
42
+ * what they asked for.
43
+ */
44
+ import { Fragment, useState } from "react";
45
+ import type { CSSProperties, ReactElement } from "react";
46
+ import { Button, Typography } from "antd";
47
+ import { useDialogSurface } from "@stapel/tokens-antd/skin";
48
+ import { useT } from "@stapel/core";
49
+ import { cssVar } from "@stapel/tokens";
50
+ import { useSearchState } from "../headless/SearchStateProvider.js";
51
+ import {
52
+ OTHER_CATEGORIES_LIMIT,
53
+ OTHER_CATEGORIES_PHONE_LIMIT,
54
+ otherCategoryLeaf,
55
+ useOtherCategories,
56
+ } from "../headless/useOtherCategories.js";
57
+ import type { OtherCategoryRow } from "../headless/useOtherCategories.js";
58
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
59
+
60
+ /**
61
+ * The height the empty-result slot holds before its answer arrives.
62
+ *
63
+ * One text line. Reserving it is the whole difference between "the sections
64
+ * appeared" and "the sections pushed the page", and it is reserved whether the
65
+ * request ends with rows or with none.
66
+ */
67
+ export const OTHER_CATEGORIES_SLOT_MIN_HEIGHT = 24;
68
+
69
+ /** Names an id path the pair cannot name on its own. Returning `undefined`
70
+ * drops the row rather than printing a number at a person. */
71
+ export type OtherCategoryNamer = (category: string) => string | undefined;
72
+
73
+ export interface OtherCategoriesLineProps {
74
+ /** How many entries before the fold (default {@link OTHER_CATEGORIES_LIMIT}). */
75
+ readonly limit?: number;
76
+ /** The same, on the phone surface (default
77
+ * {@link OTHER_CATEGORIES_PHONE_LIMIT}). */
78
+ readonly phoneLimit?: number;
79
+ /**
80
+ * What a category id path is CALLED.
81
+ *
82
+ * The pair holds `"140/145"` and has no catalogue: naming it is the host's,
83
+ * exactly as `categoryLabel` is for the chip. Without this the line still
84
+ * draws every row the server named (a `/suggest` answer already in the
85
+ * cache, or the empty-result path) and every path whose last segment is a
86
+ * slug — and drops the rest, because "163 · 149" is not a sentence.
87
+ */
88
+ readonly categoryName?: OtherCategoryNamer;
89
+ /** Skip the read entirely — mirrors `<SearchResultsPane enabled>`. */
90
+ readonly enabled?: boolean;
91
+ }
92
+
93
+ const ENTRY: CSSProperties = {
94
+ padding: 0,
95
+ height: "auto",
96
+ // The line is text: an entry has to sit ON the baseline of the words around
97
+ // it rather than in a button-shaped box of its own.
98
+ verticalAlign: "baseline",
99
+ fontSize: "inherit",
100
+ };
101
+
102
+ const COUNT: CSSProperties = { color: cssVar("text-subtle") };
103
+
104
+ /** How many rows the collapsed phone line may occupy. */
105
+ export const OTHER_CATEGORIES_PHONE_ROWS = 2;
106
+
107
+ /** The class the clamp is hung on. */
108
+ export const OTHER_CATEGORIES_CLASS = "stapel-search-other-categories";
109
+
110
+ /** The `href` the hoisted sheet is deduplicated by. */
111
+ export const OTHER_CATEGORIES_STYLE_HREF = "stapel-search-other-categories";
112
+
113
+ /**
114
+ * The clamp — two rows, collapsed, on the phone.
115
+ *
116
+ * A SHEET rather than an inline style, for the same reason the rail's
117
+ * scrollbar is one: `-webkit-line-clamp` needs `display:-webkit-box` and
118
+ * `-webkit-box-orient` together, and a vendor property set through the DOM
119
+ * style object is dropped by anything that does not already know it — which
120
+ * is how a clamp silently stops clamping.
121
+ */
122
+ export function otherCategoriesCss(): string {
123
+ const clamped = `.${OTHER_CATEGORIES_CLASS}--clamped`;
124
+ return [
125
+ `${clamped}{display:-webkit-box;-webkit-box-orient:vertical;`,
126
+ `-webkit-line-clamp:${String(OTHER_CATEGORIES_PHONE_ROWS)};overflow:hidden}`,
127
+ ].join("");
128
+ }
129
+
130
+ interface Entry {
131
+ readonly row: OtherCategoryRow;
132
+ readonly name: string;
133
+ }
134
+
135
+ export function OtherCategoriesLine(
136
+ props: OtherCategoriesLineProps
137
+ ): ReactElement | null {
138
+ const t = useT();
139
+ const { setCategory } = useSearchState();
140
+ const bag = useOtherCategories(
141
+ props.enabled !== undefined ? { enabled: props.enabled } : {}
142
+ );
143
+ const surface = useDialogSurface();
144
+ const phone = surface === "sheet";
145
+ const [expanded, setExpanded] = useState(false);
146
+
147
+ const limit = phone
148
+ ? (props.phoneLimit ?? OTHER_CATEGORIES_PHONE_LIMIT)
149
+ : (props.limit ?? OTHER_CATEGORIES_LIMIT);
150
+
151
+ const entries: Entry[] = [];
152
+ for (const row of bag.rows) {
153
+ const name =
154
+ props.categoryName?.(row.category) ?? row.name ?? otherCategoryLeaf(row.category);
155
+ if (name !== undefined) entries.push({ row, name });
156
+ }
157
+
158
+ const shown = expanded ? entries : entries.slice(0, limit);
159
+ const hidden = entries.length - shown.length;
160
+
161
+ if (entries.length === 0) {
162
+ // Nothing to say, and nothing coming: say nothing. A reserved band under a
163
+ // page that will never fill it is the same hole an empty filter column was.
164
+ if (!bag.reserving) return null;
165
+ return (
166
+ <div
167
+ data-testid="search-other-categories"
168
+ data-reserved="on"
169
+ data-source={bag.source}
170
+ style={{ minBlockSize: OTHER_CATEGORIES_SLOT_MIN_HEIGHT }}
171
+ />
172
+ );
173
+ }
174
+
175
+ return (
176
+ <Typography.Text
177
+ type="secondary"
178
+ data-testid="search-other-categories"
179
+ data-source={bag.source}
180
+ data-shown={shown.length}
181
+ {...(bag.reserving ? { "data-reserved": "on" } : {})}
182
+ className={
183
+ phone && !expanded
184
+ ? `${OTHER_CATEGORIES_CLASS} ${OTHER_CATEGORIES_CLASS}--clamped`
185
+ : OTHER_CATEGORIES_CLASS
186
+ }
187
+ style={
188
+ bag.reserving ? { minBlockSize: OTHER_CATEGORIES_SLOT_MIN_HEIGHT } : {}
189
+ }
190
+ >
191
+ <style href={OTHER_CATEGORIES_STYLE_HREF} precedence="default">
192
+ {otherCategoriesCss()}
193
+ </style>
194
+ {t(SEARCH_I18N_KEYS.otherCategoriesLabel)}{" "}
195
+ {shown.map((entry, index) => (
196
+ <Fragment key={entry.row.category}>
197
+ {index > 0 && <span aria-hidden="true"> · </span>}
198
+ <Button
199
+ type="link"
200
+ size="small"
201
+ style={ENTRY}
202
+ data-testid="search-other-category"
203
+ data-category={entry.row.category}
204
+ data-analytics="none"
205
+ data-analytics-reason="narrowing a search is a read, not a flow step"
206
+ aria-label={t(SEARCH_I18N_KEYS.otherCategoriesNarrow, {
207
+ name: entry.name,
208
+ })}
209
+ onClick={() => {
210
+ setCategory(entry.row.category);
211
+ }}
212
+ >
213
+ {entry.name} <span style={COUNT}>{entry.row.count}</span>
214
+ </Button>
215
+ </Fragment>
216
+ ))}
217
+ {hidden > 0 && (
218
+ <>
219
+ <span aria-hidden="true"> · </span>
220
+ <Button
221
+ type="link"
222
+ size="small"
223
+ style={ENTRY}
224
+ data-testid="search-other-categories-more"
225
+ data-analytics="none"
226
+ data-analytics-reason="unfolding a line is a read, not a flow step"
227
+ aria-expanded={false}
228
+ onClick={() => {
229
+ setExpanded(true);
230
+ }}
231
+ >
232
+ {t(SEARCH_I18N_KEYS.otherCategoriesMore, { count: hidden })}
233
+ </Button>
234
+ </>
235
+ )}
236
+ </Typography.Text>
237
+ );
238
+ }