@stapel/search-react 0.4.0 → 0.8.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 +191 -0
- package/README.md +1 -1
- package/dist/default/DegradationNotice.d.ts +7 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +17 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +40 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -0
- package/dist/default/FacetGroupControl.js +176 -0
- package/dist/default/FacetGroupControl.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +116 -22
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +28 -0
- package/dist/default/FilterChips.d.ts.map +1 -0
- package/dist/default/FilterChips.js +204 -0
- package/dist/default/FilterChips.js.map +1 -0
- package/dist/default/LanguageSelect.d.ts +9 -0
- package/dist/default/LanguageSelect.d.ts.map +1 -0
- package/dist/default/LanguageSelect.js +72 -0
- package/dist/default/LanguageSelect.js.map +1 -0
- package/dist/default/PageSizeSelect.d.ts +29 -0
- package/dist/default/PageSizeSelect.d.ts.map +1 -0
- package/dist/default/PageSizeSelect.js +25 -0
- package/dist/default/PageSizeSelect.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts +12 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -0
- package/dist/default/RangeFilterRow.js +75 -0
- package/dist/default/RangeFilterRow.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +5 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
- package/dist/default/RankingDisclosurePane.js +15 -15
- package/dist/default/RankingDisclosurePane.js.map +1 -1
- package/dist/default/SearchBox.d.ts +13 -0
- package/dist/default/SearchBox.d.ts.map +1 -0
- package/dist/default/SearchBox.js +67 -0
- package/dist/default/SearchBox.js.map +1 -0
- package/dist/default/SearchPage.d.ts +94 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +191 -8
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts +19 -18
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +178 -5
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +61 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +51 -21
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -2
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +27 -24
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/UrlIssueNotice.d.ts.map +1 -1
- package/dist/default/UrlIssueNotice.js +19 -1
- package/dist/default/UrlIssueNotice.js.map +1 -1
- package/dist/default/ViewSwitch.d.ts +66 -0
- package/dist/default/ViewSwitch.d.ts.map +1 -0
- package/dist/default/ViewSwitch.js +49 -0
- package/dist/default/ViewSwitch.js.map +1 -0
- package/dist/default/index.d.ts +38 -15
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +29 -12
- package/dist/default/index.js.map +1 -1
- package/dist/default/sortLabels.d.ts +5 -0
- package/dist/default/sortLabels.d.ts.map +1 -0
- package/dist/default/sortLabels.js +32 -0
- package/dist/default/sortLabels.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +17 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +23 -2
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useAppliedCount.d.ts +8 -0
- package/dist/headless/useAppliedCount.d.ts.map +1 -0
- package/dist/headless/useAppliedCount.js +30 -0
- package/dist/headless/useAppliedCount.js.map +1 -0
- package/dist/headless/useScorerNames.d.ts +4 -0
- package/dist/headless/useScorerNames.d.ts.map +1 -0
- package/dist/headless/useScorerNames.js +34 -0
- package/dist/headless/useScorerNames.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +44 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -0
- package/dist/headless/useSearchBox.js +116 -0
- package/dist/headless/useSearchBox.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +38 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +55 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +101 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +42 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +26 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +38 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/limits.d.ts +44 -0
- package/dist/state/limits.d.ts.map +1 -0
- package/dist/state/limits.js +44 -0
- package/dist/state/limits.js.map +1 -0
- package/dist/state/ranges.d.ts +52 -0
- package/dist/state/ranges.d.ts.map +1 -0
- package/dist/state/ranges.js +108 -0
- package/dist/state/ranges.js.map +1 -0
- package/llms.txt +16 -4
- package/manifest.json +268 -22
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/default/DegradationNotice.tsx +34 -12
- package/src/default/FacetGroupControl.tsx +312 -0
- package/src/default/FacetPanelPane.tsx +320 -87
- package/src/default/FilterChips.tsx +415 -0
- package/src/default/LanguageSelect.tsx +93 -0
- package/src/default/PageSizeSelect.tsx +80 -0
- package/src/default/RangeFilterRow.tsx +148 -0
- package/src/default/RankingDisclosurePane.tsx +67 -35
- package/src/default/SearchBox.tsx +130 -0
- package/src/default/SearchPage.tsx +454 -44
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +158 -71
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/ViewSwitch.tsx +147 -0
- package/src/default/index.ts +55 -15
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/useAppliedCount.ts +41 -0
- package/src/headless/useScorerNames.ts +36 -0
- package/src/headless/useSearchBox.ts +189 -0
- package/src/i18n/es.ts +46 -4
- package/src/i18n/keys.ts +114 -4
- package/src/i18n/ru.ts +50 -4
- package/src/index.ts +22 -2
- package/src/model/queries.ts +51 -1
- package/src/state/limits.ts +50 -0
- package/src/state/ranges.ts +144 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -24
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -39
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -66
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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 (
|
|
60
|
+
"name": "default — the antd skin (query box + typeahead, filters incl. ranges/geo/category slots + the phone chip row, results incl. the view switch, ranking) must stay out of the main bundle",
|
|
61
61
|
"path": "dist/default/index.js",
|
|
62
|
-
"limit": "
|
|
62
|
+
"limit": "18 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.
|
|
83
|
-
"@stapel/
|
|
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.
|
|
115
|
-
"@stapel/
|
|
116
|
-
"@stapel/
|
|
117
|
-
"@stapel/
|
|
118
|
-
"@stapel/
|
|
118
|
+
"@stapel/attributes-react": "^0.3.1",
|
|
119
|
+
"@stapel/core": "^0.19.0",
|
|
120
|
+
"@stapel/image": "^0.4.2",
|
|
121
|
+
"@stapel/showcase": "^0.3.0",
|
|
122
|
+
"@stapel/tokens": "^0.5.1",
|
|
123
|
+
"@stapel/tokens-antd": "^0.7.0"
|
|
119
124
|
},
|
|
120
125
|
"engines": {
|
|
121
126
|
"node": ">=22"
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import type { ReactElement } from "react";
|
|
31
31
|
import { Alert, Flex, Typography } from "antd";
|
|
32
|
+
import { fontSize, spacing } from "@stapel/tokens";
|
|
32
33
|
import { useT } from "@stapel/core";
|
|
33
34
|
import type { SearchDegradation } from "../api/types.js";
|
|
34
35
|
import { isCountNuanceOnly } from "../state/degradations.js";
|
|
@@ -47,6 +48,13 @@ export type DegradationNoticeVariant = "banner" | "inline" | "off";
|
|
|
47
48
|
export interface DegradationNoticeProps {
|
|
48
49
|
readonly degradations: readonly SearchDegradation[];
|
|
49
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;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
export function DegradationNotice(
|
|
@@ -59,25 +67,39 @@ export function DegradationNotice(
|
|
|
59
67
|
// A count nuance is not a degraded search — the count already says "N+".
|
|
60
68
|
if (isCountNuanceOnly(props.degradations)) return null;
|
|
61
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.
|
|
62
85
|
const lines = props.degradations.map((degradation) => (
|
|
63
86
|
<li key={degradation.raw} data-degradation={degradation.raw}>
|
|
64
|
-
<Typography.Text
|
|
65
|
-
{t(degradation.messageKey, {
|
|
66
|
-
scorer: degradation.scorer ?? "",
|
|
67
|
-
raw: degradation.raw,
|
|
68
|
-
})}
|
|
69
|
-
</Typography.Text>
|
|
87
|
+
<Typography.Text>{say(degradation)}</Typography.Text>
|
|
70
88
|
</li>
|
|
71
89
|
));
|
|
72
90
|
|
|
73
91
|
if (variant === "inline") {
|
|
74
92
|
return (
|
|
75
|
-
<Flex vertical gap={
|
|
76
|
-
<Typography.Text type="secondary" style={{ fontSize:
|
|
93
|
+
<Flex vertical gap={spacing[1]} data-testid="search-degraded" data-variant="inline">
|
|
94
|
+
<Typography.Text type="secondary" style={{ fontSize: fontSize.xs.fontSize }}>
|
|
77
95
|
{t(SEARCH_I18N_KEYS.degradedTitle)}
|
|
78
96
|
</Typography.Text>
|
|
79
|
-
<ul style={{ margin: 0, paddingInlineStart:
|
|
80
|
-
{
|
|
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
|
+
))}
|
|
81
103
|
</ul>
|
|
82
104
|
</Flex>
|
|
83
105
|
);
|
|
@@ -89,9 +111,9 @@ export function DegradationNotice(
|
|
|
89
111
|
showIcon
|
|
90
112
|
data-testid="search-degraded"
|
|
91
113
|
data-variant="banner"
|
|
92
|
-
|
|
114
|
+
title={t(SEARCH_I18N_KEYS.degradedTitle)}
|
|
93
115
|
description={
|
|
94
|
-
<ul style={{ margin: 0, paddingInlineStart:
|
|
116
|
+
<ul style={{ margin: 0, paddingInlineStart: spacing[5] }}>{lines}</ul>
|
|
95
117
|
}
|
|
96
118
|
/>
|
|
97
119
|
);
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FacetGroupControl>` — ONE facet group, drawn the way its own schema says
|
|
3
|
+
* it should be drawn.
|
|
4
|
+
*
|
|
5
|
+
* Until this release every group was the same thing: a flat column of
|
|
6
|
+
* checkboxes, however many options it had and whatever the category schema
|
|
7
|
+
* said about it. That is wrong in three ways a person meets on the first
|
|
8
|
+
* screen of a real catalogue:
|
|
9
|
+
*
|
|
10
|
+
* 1. **A single-choice facet is not a checkbox list.** `maxSelected: 1` is a
|
|
11
|
+
* fact the schema already carries, and `@stapel/attributes-react`'s editor
|
|
12
|
+
* already reads it to draw a segmented control rather than a set of boxes.
|
|
13
|
+
* A filter that says "pick one" with controls that say "pick any" teaches
|
|
14
|
+
* the wrong thing before the click and surprises after it.
|
|
15
|
+
* 2. **A hierarchical facet is not flat.** `hierarchical_select` carries a
|
|
16
|
+
* TREE in `config.options`; rendering its values as one alphabetical
|
|
17
|
+
* column hides that "Sedan" is under "Cars" and puts a child next to its
|
|
18
|
+
* own parent as if they were siblings.
|
|
19
|
+
* 3. **A 60-option facet is not a list, it is a wall.** Every catalogue has
|
|
20
|
+
* one — brand, model, city — and printing all of it pushes every group
|
|
21
|
+
* under it off the screen.
|
|
22
|
+
*
|
|
23
|
+
* ── The presentation is DERIVED, never configured here ────────────────────
|
|
24
|
+
*
|
|
25
|
+
* {@link facetGroupShape} reads the same `config` keys the attributes editor
|
|
26
|
+
* reads (`maxSelected`, `type`, `options`), so a facet cannot look one way in
|
|
27
|
+
* the filter and another way in the composer that produced the value. There is
|
|
28
|
+
* no `presentation` prop on this component and there must not be one: a
|
|
29
|
+
* per-call-site override is how the two halves drift.
|
|
30
|
+
*
|
|
31
|
+
* A group with NO schema (the host passed no `categoryFeatures`, or the slug
|
|
32
|
+
* is not in it) is a flat checkbox list — the honest default, and the shape
|
|
33
|
+
* every group had before.
|
|
34
|
+
*/
|
|
35
|
+
import { useState } from "react";
|
|
36
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
37
|
+
import { Button, Checkbox, Flex, Typography } from "antd";
|
|
38
|
+
import { useT } from "@stapel/core";
|
|
39
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
40
|
+
import { featureConfig, featureType } from "@stapel/attributes-react";
|
|
41
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
42
|
+
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
43
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How many options a group shows before the rest go behind "Show all".
|
|
47
|
+
*
|
|
48
|
+
* Eight is the count at which a group stops being readable at a glance and
|
|
49
|
+
* starts being a scroll: it is two more than the six the visual pass measured
|
|
50
|
+
* as the fold on a 390px phone with the sheet's own chrome above it, which
|
|
51
|
+
* leaves a group of seven whole rather than truncated by one row.
|
|
52
|
+
*/
|
|
53
|
+
export const FACET_VISIBLE_OPTIONS = 8;
|
|
54
|
+
|
|
55
|
+
/** The three shapes a facet group takes. */
|
|
56
|
+
export type FacetGroupShape = "segmented" | "nested" | "checkbox";
|
|
57
|
+
|
|
58
|
+
/** The option rows of a group, already nested where the schema nests them. */
|
|
59
|
+
export interface FacetOptionNode {
|
|
60
|
+
readonly option: FacetOption;
|
|
61
|
+
/** 0 for a root option; 1+ for a child of a `hierarchical_select` tree. */
|
|
62
|
+
readonly depth: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function numberish(value: unknown): number | undefined {
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The DEPTH of every value a `hierarchical_select` config declares, walked
|
|
71
|
+
* once — `{value: depth}`.
|
|
72
|
+
*
|
|
73
|
+
* The tree lives in `config.options[].children[]` (the same shape
|
|
74
|
+
* `@stapel/attributes-react`'s `Cascader` reads). The facets themselves arrive
|
|
75
|
+
* FLAT from the server, because an index term has no parent; the schema is the
|
|
76
|
+
* only thing that knows one value sits under another.
|
|
77
|
+
*/
|
|
78
|
+
function optionDepths(
|
|
79
|
+
raw: unknown,
|
|
80
|
+
depth = 0,
|
|
81
|
+
into: Map<string, number> = new Map()
|
|
82
|
+
): Map<string, number> {
|
|
83
|
+
if (!Array.isArray(raw)) return into;
|
|
84
|
+
for (const option of raw) {
|
|
85
|
+
if (option === null || typeof option !== "object") continue;
|
|
86
|
+
const entry = option as { value?: unknown; children?: unknown };
|
|
87
|
+
if (entry.value !== undefined && entry.value !== null) {
|
|
88
|
+
into.set(String(entry.value), depth);
|
|
89
|
+
}
|
|
90
|
+
optionDepths(entry.children, depth + 1, into);
|
|
91
|
+
}
|
|
92
|
+
return into;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Is this feature single-choice? The schema's own answer, not a guess. */
|
|
96
|
+
function singleChoice(feature: FeatureDef | undefined): boolean {
|
|
97
|
+
if (feature === undefined) return false;
|
|
98
|
+
// `maxSelected` ABSENT means unlimited — the engine's own default. Reading
|
|
99
|
+
// an absent key as 1 would turn every unconfigured facet into a radio group.
|
|
100
|
+
return numberish(featureConfig(feature)["maxSelected"]) === 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which of the three shapes a group takes.
|
|
105
|
+
*
|
|
106
|
+
* Order matters: a hierarchical facet is nested even when it is single-choice,
|
|
107
|
+
* because losing the tree costs more than losing the pills.
|
|
108
|
+
*/
|
|
109
|
+
export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
110
|
+
const feature = group.feature;
|
|
111
|
+
if (feature !== undefined && featureType(feature) === "hierarchical_select") {
|
|
112
|
+
return "nested";
|
|
113
|
+
}
|
|
114
|
+
return singleChoice(feature) ? "segmented" : "checkbox";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The group's options in render order, carrying the depth the schema gives. */
|
|
118
|
+
export function facetOptionNodes(group: FacetGroup): readonly FacetOptionNode[] {
|
|
119
|
+
if (facetGroupShape(group) !== "nested" || group.feature === undefined) {
|
|
120
|
+
return group.options.map((option) => ({ option, depth: 0 }));
|
|
121
|
+
}
|
|
122
|
+
const depths = optionDepths(featureConfig(group.feature)["options"]);
|
|
123
|
+
// Depth alone would leave children beside strangers: the options are
|
|
124
|
+
// re-ordered so each child follows its own parent, and a value the schema
|
|
125
|
+
// does not know keeps depth 0 rather than being hidden under someone.
|
|
126
|
+
const byValue = new Map(group.options.map((option) => [option.value, option]));
|
|
127
|
+
const out: FacetOptionNode[] = [];
|
|
128
|
+
const emitted = new Set<string>();
|
|
129
|
+
for (const [value, depth] of depths) {
|
|
130
|
+
const option = byValue.get(value);
|
|
131
|
+
if (option === undefined) continue;
|
|
132
|
+
out.push({ option, depth });
|
|
133
|
+
emitted.add(value);
|
|
134
|
+
}
|
|
135
|
+
for (const option of group.options) {
|
|
136
|
+
if (!emitted.has(option.value)) out.push({ option, depth: 0 });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A count, or the honest "we did not count this". Never a zero standing in
|
|
142
|
+
* for an absent answer. */
|
|
143
|
+
function OptionCount(props: {
|
|
144
|
+
readonly group: FacetGroup;
|
|
145
|
+
readonly option: FacetOption;
|
|
146
|
+
}): ReactElement {
|
|
147
|
+
const t = useT();
|
|
148
|
+
return (
|
|
149
|
+
<Typography.Text
|
|
150
|
+
type="secondary"
|
|
151
|
+
data-testid={`facet-count-${props.group.slug}-${props.option.value}`}
|
|
152
|
+
>
|
|
153
|
+
{props.option.count === null
|
|
154
|
+
? t(SEARCH_I18N_KEYS.facetsNotCounted)
|
|
155
|
+
: props.option.count}
|
|
156
|
+
</Typography.Text>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The indent one level of a hierarchical facet is drawn with. */
|
|
161
|
+
const NEST_STEP = spacing[5];
|
|
162
|
+
|
|
163
|
+
function CheckboxRow(props: {
|
|
164
|
+
readonly group: FacetGroup;
|
|
165
|
+
readonly node: FacetOptionNode;
|
|
166
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
167
|
+
}): ReactElement {
|
|
168
|
+
const { group, node } = props;
|
|
169
|
+
return (
|
|
170
|
+
<Flex
|
|
171
|
+
justify="space-between"
|
|
172
|
+
align="center"
|
|
173
|
+
gap={spacing[2]}
|
|
174
|
+
style={
|
|
175
|
+
node.depth > 0
|
|
176
|
+
? { paddingInlineStart: node.depth * NEST_STEP }
|
|
177
|
+
: undefined
|
|
178
|
+
}
|
|
179
|
+
data-depth={node.depth}
|
|
180
|
+
>
|
|
181
|
+
<Checkbox
|
|
182
|
+
checked={node.option.selected}
|
|
183
|
+
data-testid={`facet-option-${group.slug}-${node.option.value}`}
|
|
184
|
+
data-analytics="none"
|
|
185
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
186
|
+
onChange={() => {
|
|
187
|
+
props.onToggle(group.slug, node.option.value);
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
{node.option.label}
|
|
191
|
+
</Checkbox>
|
|
192
|
+
<OptionCount group={group} option={node.option} />
|
|
193
|
+
</Flex>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One pill of a single-choice group.
|
|
199
|
+
*
|
|
200
|
+
* A real `<button aria-pressed>` rather than antd's `Segmented`: the count has
|
|
201
|
+
* to sit inside the pill (a filter without its remaining count is the
|
|
202
|
+
* drill-down facet turned naive), and `Segmented` takes labels, not rows.
|
|
203
|
+
* `aria-pressed` is what makes a styled button a TOGGLE to a screen reader
|
|
204
|
+
* instead of a link-shaped thing that mysteriously changes the page.
|
|
205
|
+
*/
|
|
206
|
+
function OptionPill(props: {
|
|
207
|
+
readonly group: FacetGroup;
|
|
208
|
+
readonly option: FacetOption;
|
|
209
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
210
|
+
}): ReactElement {
|
|
211
|
+
const { group, option } = props;
|
|
212
|
+
const style: CSSProperties = { borderRadius: radii.full };
|
|
213
|
+
return (
|
|
214
|
+
<Button
|
|
215
|
+
size="small"
|
|
216
|
+
shape="round"
|
|
217
|
+
type={option.selected ? "primary" : "default"}
|
|
218
|
+
aria-pressed={option.selected}
|
|
219
|
+
style={style}
|
|
220
|
+
data-testid={`facet-option-${group.slug}-${option.value}`}
|
|
221
|
+
data-analytics="none"
|
|
222
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
223
|
+
onClick={() => {
|
|
224
|
+
props.onToggle(group.slug, option.value);
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{option.count === null ? option.label : `${option.label} ${option.count}`}
|
|
228
|
+
</Button>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface FacetGroupControlProps {
|
|
233
|
+
readonly group: FacetGroup;
|
|
234
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
235
|
+
/** Draw the group's own name above its options. `false` for a surface that
|
|
236
|
+
* has already said it — a per-group sheet whose title IS the group. */
|
|
237
|
+
readonly heading?: boolean;
|
|
238
|
+
/** How many options before "Show all". Default {@link FACET_VISIBLE_OPTIONS};
|
|
239
|
+
* `null` shows every option (a sheet devoted to one group has the room). */
|
|
240
|
+
readonly visibleOptions?: number | null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
244
|
+
const t = useT();
|
|
245
|
+
const { group } = props;
|
|
246
|
+
const [expanded, setExpanded] = useState(false);
|
|
247
|
+
const shape = facetGroupShape(group);
|
|
248
|
+
const nodes = facetOptionNodes(group);
|
|
249
|
+
|
|
250
|
+
const limit =
|
|
251
|
+
props.visibleOptions === null
|
|
252
|
+
? null
|
|
253
|
+
: (props.visibleOptions ?? FACET_VISIBLE_OPTIONS);
|
|
254
|
+
// A group one row over the limit is not truncated: hiding a single option
|
|
255
|
+
// behind "Show all (9)" costs a tap to reveal exactly one thing.
|
|
256
|
+
const collapsible = limit !== null && nodes.length > limit + 1;
|
|
257
|
+
const shown = collapsible && !expanded ? nodes.slice(0, limit) : nodes;
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<Flex
|
|
261
|
+
vertical
|
|
262
|
+
gap={shape === "segmented" ? spacing[2] : spacing[1]}
|
|
263
|
+
data-testid={`facet-group-${group.slug}`}
|
|
264
|
+
data-counted={group.counted ? "true" : "false"}
|
|
265
|
+
data-shape={shape}
|
|
266
|
+
>
|
|
267
|
+
{props.heading !== false && (
|
|
268
|
+
<Typography.Text strong>{group.label}</Typography.Text>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{shape === "segmented" ? (
|
|
272
|
+
<Flex wrap gap={spacing[2]}>
|
|
273
|
+
{shown.map((node) => (
|
|
274
|
+
<OptionPill
|
|
275
|
+
key={node.option.value}
|
|
276
|
+
group={group}
|
|
277
|
+
option={node.option}
|
|
278
|
+
onToggle={props.onToggle}
|
|
279
|
+
/>
|
|
280
|
+
))}
|
|
281
|
+
</Flex>
|
|
282
|
+
) : (
|
|
283
|
+
shown.map((node) => (
|
|
284
|
+
<CheckboxRow
|
|
285
|
+
key={node.option.value}
|
|
286
|
+
group={group}
|
|
287
|
+
node={node}
|
|
288
|
+
onToggle={props.onToggle}
|
|
289
|
+
/>
|
|
290
|
+
))
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{collapsible && (
|
|
294
|
+
<Button
|
|
295
|
+
type="link"
|
|
296
|
+
size="small"
|
|
297
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
298
|
+
data-testid={`facet-more-${group.slug}`}
|
|
299
|
+
data-analytics="none"
|
|
300
|
+
data-analytics-reason="expanding a filter group is a read, not a flow step"
|
|
301
|
+
onClick={() => {
|
|
302
|
+
setExpanded((was) => !was);
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
{expanded
|
|
306
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
307
|
+
: t(SEARCH_I18N_KEYS.facetsShowAll, { count: nodes.length })}
|
|
308
|
+
</Button>
|
|
309
|
+
)}
|
|
310
|
+
</Flex>
|
|
311
|
+
);
|
|
312
|
+
}
|