@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.
- package/CHANGELOG.md +120 -0
- package/README.md +76 -2
- package/dist/default/FilterChips.d.ts +86 -2
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +208 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/OtherCategoriesLine.d.ts +49 -0
- package/dist/default/OtherCategoriesLine.d.ts.map +1 -0
- package/dist/default/OtherCategoriesLine.js +127 -0
- package/dist/default/OtherCategoriesLine.js.map +1 -0
- package/dist/default/SearchPage.d.ts +36 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +9 -4
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +15 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +4 -1
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +4 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +2 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/useOtherCategories.d.ts +52 -0
- package/dist/headless/useOtherCategories.d.ts.map +1 -0
- package/dist/headless/useOtherCategories.js +92 -0
- package/dist/headless/useOtherCategories.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +42 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +53 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +10 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +34 -1
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FilterChips.tsx +308 -4
- package/src/default/OtherCategoriesLine.tsx +238 -0
- package/src/default/SearchPage.tsx +65 -1
- package/src/default/SearchResultsPane.tsx +28 -0
- package/src/default/index.ts +28 -1
- package/src/headless/useOtherCategories.ts +193 -0
- package/src/i18n/es.ts +10 -0
- package/src/i18n/keys.ts +57 -0
- package/src/i18n/ru.ts +11 -0
- package/src/index.ts +10 -0
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<FilterChips>` — the
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|