@stapel/search-react 0.21.0 → 0.23.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 +141 -0
- package/MODULE.md +12 -0
- package/README.md +143 -2
- package/dist/api/types.d.ts +37 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +53 -5
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +189 -8
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +41 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +72 -18
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +2 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +37 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +120 -0
- package/dist/default/PartitionChips.js.map +1 -0
- package/dist/default/PopularValues.d.ts +58 -0
- package/dist/default/PopularValues.d.ts.map +1 -0
- package/dist/default/PopularValues.js +46 -0
- package/dist/default/PopularValues.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +65 -11
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +49 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +43 -2
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +7 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +8 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/useFacetLabels.js +7 -7
- package/dist/headless/useFacetLabels.js.map +1 -1
- 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 +52 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +62 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +90 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +216 -15
- package/dist/state/facets.js.map +1 -1
- package/dist/state/ranges.d.ts +22 -0
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +30 -0
- package/dist/state/ranges.js.map +1 -1
- package/dist/state/translit.d.ts +58 -0
- package/dist/state/translit.d.ts.map +1 -0
- package/dist/state/translit.js +115 -0
- package/dist/state/translit.js.map +1 -0
- package/llms.txt +3 -1
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +42 -3
- package/src/default/FacetGroupControl.tsx +339 -9
- package/src/default/FacetPanelPane.tsx +131 -19
- package/src/default/FilterChips.tsx +5 -2
- package/src/default/PartitionChips.tsx +204 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/RangeFilterRow.tsx +116 -1
- package/src/default/SearchPage.tsx +61 -2
- package/src/default/index.ts +27 -2
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +9 -0
- package/src/index.ts +13 -0
- package/src/state/facets.ts +259 -27
- package/src/state/ranges.ts +44 -0
- package/src/state/translit.ts +113 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<PartitionChips>` — the children of a `chips` category, as one row of
|
|
3
|
+
* single-select chips.
|
|
4
|
+
*
|
|
5
|
+
* A partition is a category whose children are not subcategories but one
|
|
6
|
+
* template split by a value their names express: buy / sell / let / rent,
|
|
7
|
+
* new / used, for boys / for girls. They keep their
|
|
8
|
+
* ids, their paths and their URLs — a listing still lands on a child — and
|
|
9
|
+
* only the PRESENTATION changes: the parent draws a feed, and the children
|
|
10
|
+
* are a choice above it rather than a grid of tiles the visitor has to pass
|
|
11
|
+
* through.
|
|
12
|
+
*
|
|
13
|
+
* Which categories are a partition is not decided here and not decided by
|
|
14
|
+
* this pair: `children_as` is a stored, derivable field on the category, and
|
|
15
|
+
* the storefront hands this component the children it resolved. What this
|
|
16
|
+
* component owns is that the choice is SINGLE-select and that "all" — the
|
|
17
|
+
* parent, unnarrowed — is one of the options rather than a way of clearing
|
|
18
|
+
* the others.
|
|
19
|
+
*
|
|
20
|
+
* ── Why a radiogroup and not a row of toggles ─────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Because exactly one of them is true at a time, and `aria-pressed` buttons
|
|
23
|
+
* say the opposite: they announce a set of independent switches, so a screen
|
|
24
|
+
* reader user hears no reason why pressing one released another. A radiogroup
|
|
25
|
+
* with roving tabindex is the pattern for "one of these": Tab reaches the row
|
|
26
|
+
* once and lands on the chosen chip, the arrow keys move along it, and the
|
|
27
|
+
* group's own name says what is being chosen.
|
|
28
|
+
*/
|
|
29
|
+
import { useRef } from "react";
|
|
30
|
+
import type {
|
|
31
|
+
CSSProperties,
|
|
32
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
33
|
+
ReactElement,
|
|
34
|
+
ReactNode,
|
|
35
|
+
} from "react";
|
|
36
|
+
import { Button } from "antd";
|
|
37
|
+
import { useT } from "@stapel/core";
|
|
38
|
+
import { cssVar, radii, spacing } from "@stapel/tokens";
|
|
39
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
40
|
+
|
|
41
|
+
/** One child of a partitioned category. `path` is the slash-joined id path
|
|
42
|
+
* the `category` parameter takes — the same string `SearchQueryState.category`
|
|
43
|
+
* carries, so a host never rebuilds it from ids. */
|
|
44
|
+
export interface PartitionChild {
|
|
45
|
+
readonly id: number | string;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PartitionChipsProps {
|
|
51
|
+
/** The children, in the order the catalogue declares them. */
|
|
52
|
+
readonly items: readonly PartitionChild[];
|
|
53
|
+
/** The chosen child's `path`, or `null` for the parent itself. CONTROLLED:
|
|
54
|
+
* this row keeps no state, because the choice is a `category` in the URL. */
|
|
55
|
+
readonly value: string | null;
|
|
56
|
+
readonly onChange: (path: string | null) => void;
|
|
57
|
+
/** The first chip's label. Defaults to `search.partition.all`. */
|
|
58
|
+
readonly allLabel?: ReactNode;
|
|
59
|
+
/** The row's accessible name. Defaults to `search.partition.label`. */
|
|
60
|
+
readonly label?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Which shape the row takes. `"chips"` (the default) is the phone's: a
|
|
63
|
+
* wrapping row of rounded pills above the feed. `"segmented"` is the
|
|
64
|
+
* desktop RAIL's — one joined control under the axis's own label, which is
|
|
65
|
+
* how the reference classified draws the same choice (a car-type row:
|
|
66
|
+
* all, used, new) and what a 280px column has room for.
|
|
67
|
+
*
|
|
68
|
+
* The SEMANTICS do not vary with it. Both are the same `radiogroup` with
|
|
69
|
+
* the same roving tabindex and the same arrow keys — a segmented look is a
|
|
70
|
+
* border-radius decision, and swapping in a component that draws joined
|
|
71
|
+
* cells by giving up "exactly one of these is true" would trade the
|
|
72
|
+
* accessible half of this control for the visible half.
|
|
73
|
+
*/
|
|
74
|
+
readonly variant?: "chips" | "segmented";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const CHIP: CSSProperties = { borderRadius: radii.full };
|
|
78
|
+
|
|
79
|
+
const ROW: CSSProperties = {
|
|
80
|
+
display: "flex",
|
|
81
|
+
flexWrap: "wrap",
|
|
82
|
+
gap: spacing[2],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The segmented row: one joined control, no gaps, the group's own outline.
|
|
87
|
+
*
|
|
88
|
+
* `gap: 0` plus a shared border is what makes three buttons read as one
|
|
89
|
+
* control — the thing a rail needs, because a wrapping pill row in a 280px
|
|
90
|
+
* column is two ragged lines. `overflow: hidden` clips the cells' own corners
|
|
91
|
+
* to the group's radius so the ends are round and the joins are square.
|
|
92
|
+
*/
|
|
93
|
+
const SEGMENTED_ROW: CSSProperties = {
|
|
94
|
+
display: "flex",
|
|
95
|
+
gap: 0,
|
|
96
|
+
inlineSize: "100%",
|
|
97
|
+
border: `1px solid ${cssVar("border")}`,
|
|
98
|
+
borderRadius: cssVar("radius-md"),
|
|
99
|
+
overflow: "hidden",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** One cell of the segmented row: an equal share of the width, square joins,
|
|
103
|
+
* no border of its own — the group draws the outline. */
|
|
104
|
+
const SEGMENTED_CELL: CSSProperties = {
|
|
105
|
+
flex: "1 1 0",
|
|
106
|
+
minInlineSize: 0,
|
|
107
|
+
borderRadius: 0,
|
|
108
|
+
borderInline: "none",
|
|
109
|
+
borderBlock: "none",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The row's cells, as `[value, label]` — the parent first, then the
|
|
113
|
+
* children in catalogue order. */
|
|
114
|
+
function cells(
|
|
115
|
+
items: readonly PartitionChild[],
|
|
116
|
+
allLabel: ReactNode
|
|
117
|
+
): readonly (readonly [string | null, ReactNode])[] {
|
|
118
|
+
return [
|
|
119
|
+
[null, allLabel] as const,
|
|
120
|
+
...items.map((item) => [item.path, item.name] as const),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
125
|
+
const t = useT();
|
|
126
|
+
const row = useRef<HTMLDivElement>(null);
|
|
127
|
+
const options = cells(
|
|
128
|
+
props.items,
|
|
129
|
+
props.allLabel ?? t(SEARCH_I18N_KEYS.partitionAll)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Arrow keys move the choice AND the focus, which is what a radiogroup
|
|
134
|
+
* does: in a single-select row the focused option is the selected one, so
|
|
135
|
+
* moving focus without choosing would leave the two disagreeing.
|
|
136
|
+
*/
|
|
137
|
+
const onKeyDown =
|
|
138
|
+
(index: number) =>
|
|
139
|
+
(event: ReactKeyboardEvent): void => {
|
|
140
|
+
const step =
|
|
141
|
+
event.key === "ArrowRight" || event.key === "ArrowDown"
|
|
142
|
+
? 1
|
|
143
|
+
: event.key === "ArrowLeft" || event.key === "ArrowUp"
|
|
144
|
+
? -1
|
|
145
|
+
: event.key === "Home"
|
|
146
|
+
? -index
|
|
147
|
+
: event.key === "End"
|
|
148
|
+
? options.length - 1 - index
|
|
149
|
+
: 0;
|
|
150
|
+
if (step === 0) return;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
const next = (index + step + options.length) % options.length;
|
|
153
|
+
const cell = options[next];
|
|
154
|
+
if (cell === undefined) return;
|
|
155
|
+
props.onChange(cell[0]);
|
|
156
|
+
const buttons = row.current?.querySelectorAll<HTMLElement>('[role="radio"]');
|
|
157
|
+
buttons?.[next]?.focus();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// A `value` naming no cell (a link into a child that has since moved) must
|
|
161
|
+
// still leave the row reachable by Tab, so the roving stop falls back to the
|
|
162
|
+
// parent chip rather than vanishing.
|
|
163
|
+
const active = options.findIndex(([value]) => value === props.value);
|
|
164
|
+
const stop = active >= 0 ? active : 0;
|
|
165
|
+
|
|
166
|
+
const segmented = props.variant === "segmented";
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
style={segmented ? SEGMENTED_ROW : ROW}
|
|
170
|
+
data-variant={segmented ? "segmented" : "chips"}
|
|
171
|
+
ref={row}
|
|
172
|
+
role="radiogroup"
|
|
173
|
+
aria-label={props.label ?? t(SEARCH_I18N_KEYS.partitionLabel)}
|
|
174
|
+
data-testid="partition-chips"
|
|
175
|
+
>
|
|
176
|
+
{options.map(([value, label], index) => {
|
|
177
|
+
const selected = value === props.value;
|
|
178
|
+
return (
|
|
179
|
+
<Button
|
|
180
|
+
key={value ?? "__all__"}
|
|
181
|
+
size="small"
|
|
182
|
+
{...(segmented ? {} : { shape: "round" as const })}
|
|
183
|
+
type={selected ? "primary" : "default"}
|
|
184
|
+
role="radio"
|
|
185
|
+
aria-checked={selected}
|
|
186
|
+
// Roving tabindex: the row is ONE Tab stop and it lands on the
|
|
187
|
+
// chosen chip, not on the first of eight.
|
|
188
|
+
tabIndex={index === stop ? 0 : -1}
|
|
189
|
+
style={segmented ? SEGMENTED_CELL : CHIP}
|
|
190
|
+
data-testid={`partition-chip-${value ?? "all"}`}
|
|
191
|
+
data-analytics="none"
|
|
192
|
+
data-analytics-reason="choosing a section is a read, not a flow step"
|
|
193
|
+
onKeyDown={onKeyDown(index)}
|
|
194
|
+
onClick={() => {
|
|
195
|
+
props.onChange(value);
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
{label}
|
|
199
|
+
</Button>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<PopularValues>` — the busiest values of one facet, printed as words a
|
|
3
|
+
* person can click, above the results.
|
|
4
|
+
*
|
|
5
|
+
* A dictionary facet in the rail answers "narrow this list"; it does not
|
|
6
|
+
* answer "what is IN this category". On a feed page of a cars leaf the second
|
|
7
|
+
* question is the one a visitor arrives with, and its answer is already in the
|
|
8
|
+
* envelope: `Toyota 802` is a bucket and its count. Printed as a multi-column
|
|
9
|
+
* block it is a table of contents for the category — the eleven makes that
|
|
10
|
+
* account for most of it, in one glance, each one a filter.
|
|
11
|
+
*
|
|
12
|
+
* ── What it is NOT ────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Not a replacement for the facet control: it shows the busy head of ONE
|
|
15
|
+
* group and says so with a link into the whole thing (`onShowAll`). Not a
|
|
16
|
+
* second source of counts either — the numbers are the answer's own
|
|
17
|
+
* drill-down counts, the same ones the checkbox rows carry, so a value cannot
|
|
18
|
+
* read `802` here and `93` in the panel.
|
|
19
|
+
*
|
|
20
|
+
* ── Hidden on a phone by a PROP ───────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* `hidden` rather than a media query inside, because whether a 390px screen
|
|
23
|
+
* has room for a block of forty links is a decision about the PAGE, and the
|
|
24
|
+
* page is the storefront's. A component that hid itself below some width of
|
|
25
|
+
* its own choosing would take that decision away from the only surface that
|
|
26
|
+
* knows what else is on screen — and would still render the DOM, which is
|
|
27
|
+
* what `display: none` costs a screen reader.
|
|
28
|
+
*/
|
|
29
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
30
|
+
import { Button, Flex, Typography } from "antd";
|
|
31
|
+
import { useT } from "@stapel/core";
|
|
32
|
+
import { spacing } from "@stapel/tokens";
|
|
33
|
+
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
34
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
35
|
+
|
|
36
|
+
/** How many values the block prints before the link into the full control. */
|
|
37
|
+
export const POPULAR_VALUES_LIMIT = 12;
|
|
38
|
+
|
|
39
|
+
/** How many columns the list flows into. Three fills a desktop content column
|
|
40
|
+
* without turning a make into a two-line wrap. */
|
|
41
|
+
export const POPULAR_VALUES_COLUMNS = 3;
|
|
42
|
+
|
|
43
|
+
/** A value with no evidence behind it is not a popular value. Uncounted
|
|
44
|
+
* options carry `count: null` and are dropped here rather than printed with a
|
|
45
|
+
* blank where the number belongs — the block IS the numbers. */
|
|
46
|
+
function hasEvidence(option: FacetOption): boolean {
|
|
47
|
+
return option.count !== null && option.count > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The busiest values of the group, count-descending, capped. */
|
|
51
|
+
export function popularOptions(
|
|
52
|
+
group: FacetGroup,
|
|
53
|
+
limit: number = POPULAR_VALUES_LIMIT
|
|
54
|
+
): readonly FacetOption[] {
|
|
55
|
+
return [...group.options.filter(hasEvidence)]
|
|
56
|
+
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
|
|
57
|
+
.slice(0, limit);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ROW: CSSProperties = {
|
|
61
|
+
// `break-inside` keeps a value and its count on one line when the browser
|
|
62
|
+
// decides where the column ends.
|
|
63
|
+
breakInside: "avoid",
|
|
64
|
+
display: "flex",
|
|
65
|
+
gap: spacing[2],
|
|
66
|
+
alignItems: "baseline",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface PopularValuesProps {
|
|
70
|
+
/** The group to print — normally the first `ref_select` of the plan. */
|
|
71
|
+
readonly group: FacetGroup;
|
|
72
|
+
/** Apply one value. Same signature as the panel's `toggle`, so a host can
|
|
73
|
+
* hand the facet bag's own function straight in. */
|
|
74
|
+
readonly onApply: (slug: string, value: string) => void;
|
|
75
|
+
/** Draw nothing. The phone, decided by the page — see the module note. */
|
|
76
|
+
readonly hidden?: boolean;
|
|
77
|
+
/** How many values. Default {@link POPULAR_VALUES_LIMIT}. */
|
|
78
|
+
readonly limit?: number;
|
|
79
|
+
/** How many columns. Default {@link POPULAR_VALUES_COLUMNS}. */
|
|
80
|
+
readonly columns?: number;
|
|
81
|
+
/** The block's heading. Defaults to the group's own label; `null` draws
|
|
82
|
+
* none, for a surface that has already named the axis. */
|
|
83
|
+
readonly heading?: ReactNode;
|
|
84
|
+
/** Open the full control. Absent draws no link — a link that goes nowhere
|
|
85
|
+
* is worse than a block that stops. */
|
|
86
|
+
readonly onShowAll?: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
90
|
+
const t = useT();
|
|
91
|
+
const { group } = props;
|
|
92
|
+
if (props.hidden === true) return null;
|
|
93
|
+
const options = popularOptions(group, props.limit ?? POPULAR_VALUES_LIMIT);
|
|
94
|
+
if (options.length === 0) return null;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Flex
|
|
98
|
+
vertical
|
|
99
|
+
gap={spacing[2]}
|
|
100
|
+
data-testid={`popular-values-${group.slug}`}
|
|
101
|
+
data-label-source={group.labelSource}
|
|
102
|
+
>
|
|
103
|
+
{props.heading !== null && (
|
|
104
|
+
<Typography.Text strong>{props.heading ?? group.label}</Typography.Text>
|
|
105
|
+
)}
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
columnCount: props.columns ?? POPULAR_VALUES_COLUMNS,
|
|
109
|
+
columnGap: spacing[4],
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{options.map((option) => (
|
|
113
|
+
<div key={option.value} style={ROW}>
|
|
114
|
+
<Button
|
|
115
|
+
type="link"
|
|
116
|
+
size="small"
|
|
117
|
+
style={{ paddingInline: 0, height: "auto" }}
|
|
118
|
+
data-testid={`popular-value-${group.slug}-${option.value}`}
|
|
119
|
+
data-analytics="none"
|
|
120
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
121
|
+
onClick={() => {
|
|
122
|
+
props.onApply(group.slug, option.value);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{option.label}
|
|
126
|
+
</Button>
|
|
127
|
+
<Typography.Text
|
|
128
|
+
type="secondary"
|
|
129
|
+
data-testid={`popular-count-${group.slug}-${option.value}`}
|
|
130
|
+
>
|
|
131
|
+
{option.count}
|
|
132
|
+
</Typography.Text>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
{props.onShowAll !== undefined && (
|
|
137
|
+
<Button
|
|
138
|
+
type="link"
|
|
139
|
+
size="small"
|
|
140
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
141
|
+
data-testid={`popular-all-${group.slug}`}
|
|
142
|
+
data-analytics="none"
|
|
143
|
+
data-analytics-reason="opening a filter control is a read, not a flow step"
|
|
144
|
+
onClick={props.onShowAll}
|
|
145
|
+
>
|
|
146
|
+
{t(SEARCH_I18N_KEYS.facetsPopularAll)}
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
149
|
+
</Flex>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { useRef, useState } from "react";
|
|
19
19
|
import type { ReactElement } from "react";
|
|
20
|
-
import { Button, Flex, InputNumber, Typography } from "antd";
|
|
20
|
+
import { Button, Flex, InputNumber, Select, Typography } from "antd";
|
|
21
21
|
import { actionAvailable, actionBlocked, useFormat, useT } from "@stapel/core";
|
|
22
22
|
import type { ActionAvailability } from "@stapel/core";
|
|
23
23
|
import { GatedButton } from "@stapel/tokens-antd/skin";
|
|
@@ -70,6 +70,100 @@ function boundSuffix(
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* One bound of a PICKER axis — a bounded integer small enough to choose from
|
|
75
|
+
* (`RangeGroup.picker`), which on a cars leaf is the year.
|
|
76
|
+
*
|
|
77
|
+
* The founder's walk of the live page put it plainly: the year was a bare
|
|
78
|
+
* number field. A year is not a quantity a person computes, it is one of a
|
|
79
|
+
* hundred-odd values, and every classified draws it as two pickers.
|
|
80
|
+
*
|
|
81
|
+
* Typing still works, and the rule is the same one `attributes-react` is
|
|
82
|
+
* building for a bounded integer field: what a person types is a value, not a
|
|
83
|
+
* query. While the typed text IS a valid value inside the bounds the list
|
|
84
|
+
* narrows to it and gets out of the way; the moment it is not — a letter, a
|
|
85
|
+
* year before the catalogue's own floor — the full list comes back WITH the
|
|
86
|
+
* bounds said in words, because "1899" silently doing nothing is the defect
|
|
87
|
+
* this replaces.
|
|
88
|
+
*/
|
|
89
|
+
function BoundPicker(props: {
|
|
90
|
+
readonly group: RangeGroup;
|
|
91
|
+
readonly bound: "from" | "to";
|
|
92
|
+
readonly value: string;
|
|
93
|
+
readonly onChange: (value: string) => void;
|
|
94
|
+
readonly onCommit: () => void;
|
|
95
|
+
}): ReactElement {
|
|
96
|
+
const t = useT();
|
|
97
|
+
const { group } = props;
|
|
98
|
+
const [typed, setTyped] = useState("");
|
|
99
|
+
const values = group.picker ?? [];
|
|
100
|
+
const min = group.min ?? values[values.length - 1];
|
|
101
|
+
const max = group.max ?? values[0];
|
|
102
|
+
const query = typed.trim();
|
|
103
|
+
const asNumber = Number(query);
|
|
104
|
+
// "Valid" is the whole rule: a number, whole, and inside the axis's own
|
|
105
|
+
// bounds. Anything else is a query that matched nothing, not a value.
|
|
106
|
+
const valid =
|
|
107
|
+
query !== "" &&
|
|
108
|
+
/^-?\d+$/.test(query) &&
|
|
109
|
+
Number.isInteger(asNumber) &&
|
|
110
|
+
(min === undefined || asNumber >= min) &&
|
|
111
|
+
(max === undefined || asNumber <= max);
|
|
112
|
+
const listed = valid
|
|
113
|
+
? values.filter((value) => String(value).startsWith(query))
|
|
114
|
+
: values;
|
|
115
|
+
return (
|
|
116
|
+
<Flex vertical gap={spacing[1]}>
|
|
117
|
+
<Select
|
|
118
|
+
showSearch
|
|
119
|
+
allowClear
|
|
120
|
+
value={props.value === "" ? null : props.value}
|
|
121
|
+
searchValue={typed}
|
|
122
|
+
// The list is already narrowed above, by the rule this control is
|
|
123
|
+
// about; antd's own substring filter would narrow it a second time
|
|
124
|
+
// and by a different rule.
|
|
125
|
+
filterOption={false}
|
|
126
|
+
placeholder={t(
|
|
127
|
+
props.bound === "from"
|
|
128
|
+
? SEARCH_I18N_KEYS.facetsRangeFrom
|
|
129
|
+
: SEARCH_I18N_KEYS.facetsRangeTo
|
|
130
|
+
)}
|
|
131
|
+
aria-label={t(
|
|
132
|
+
props.bound === "from"
|
|
133
|
+
? SEARCH_I18N_KEYS.facetsRangeFromAria
|
|
134
|
+
: SEARCH_I18N_KEYS.facetsRangeToAria,
|
|
135
|
+
{ feature: group.label }
|
|
136
|
+
)}
|
|
137
|
+
data-testid={`facet-range-${group.slug}-${props.bound}`}
|
|
138
|
+
style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
|
|
139
|
+
options={listed.map((value) => ({
|
|
140
|
+
value: String(value),
|
|
141
|
+
label: String(value),
|
|
142
|
+
}))}
|
|
143
|
+
onSearch={setTyped}
|
|
144
|
+
onChange={(next: string | null) => {
|
|
145
|
+
setTyped("");
|
|
146
|
+
props.onChange(next ?? "");
|
|
147
|
+
}}
|
|
148
|
+
onBlur={props.onCommit}
|
|
149
|
+
/>
|
|
150
|
+
{/* Said only when the typing has gone outside the axis — a hint over an
|
|
151
|
+
untouched field is chrome. */}
|
|
152
|
+
{query !== "" && !valid && (
|
|
153
|
+
<Typography.Text
|
|
154
|
+
type="secondary"
|
|
155
|
+
data-testid={`facet-range-${group.slug}-${props.bound}-bounds`}
|
|
156
|
+
>
|
|
157
|
+
{t(SEARCH_I18N_KEYS.facetsRangeBounds, {
|
|
158
|
+
min: String(min ?? ""),
|
|
159
|
+
max: String(max ?? ""),
|
|
160
|
+
})}
|
|
161
|
+
</Typography.Text>
|
|
162
|
+
)}
|
|
163
|
+
</Flex>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
73
167
|
export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
74
168
|
const t = useT();
|
|
75
169
|
const format = useFormat();
|
|
@@ -138,6 +232,25 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
138
232
|
{suffix === undefined ? group.label : `${group.label}, ${suffix}`}
|
|
139
233
|
</Typography.Text>
|
|
140
234
|
<Flex gap={spacing[2]} align="center" wrap>
|
|
235
|
+
{group.picker !== undefined ? (
|
|
236
|
+
<>
|
|
237
|
+
<BoundPicker
|
|
238
|
+
group={group}
|
|
239
|
+
bound="from"
|
|
240
|
+
value={from}
|
|
241
|
+
onChange={setFrom}
|
|
242
|
+
onCommit={commit}
|
|
243
|
+
/>
|
|
244
|
+
<BoundPicker
|
|
245
|
+
group={group}
|
|
246
|
+
bound="to"
|
|
247
|
+
value={to}
|
|
248
|
+
onChange={setTo}
|
|
249
|
+
onCommit={commit}
|
|
250
|
+
/>
|
|
251
|
+
</>
|
|
252
|
+
) : (
|
|
253
|
+
<>
|
|
141
254
|
<InputNumber
|
|
142
255
|
value={from === "" ? null : Number(from)}
|
|
143
256
|
placeholder={t(SEARCH_I18N_KEYS.facetsRangeFrom)}
|
|
@@ -172,6 +285,8 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
172
285
|
}}
|
|
173
286
|
onPressEnter={commit}
|
|
174
287
|
/>
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
175
290
|
{/* Primary when there is something to apply, secondary when there is
|
|
176
291
|
not. It used to be the other way round — filled over two empty
|
|
177
292
|
fields, ghosted the moment the person had typed the numbers the
|
|
@@ -66,7 +66,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
|
66
66
|
import { Button, Flex } from "antd";
|
|
67
67
|
import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
68
68
|
import { useT, useTPlural } from "@stapel/core";
|
|
69
|
-
import { spacing } from "@stapel/tokens";
|
|
69
|
+
import { cssVar, spacing } from "@stapel/tokens";
|
|
70
70
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
71
71
|
import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
|
|
72
72
|
import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
|
|
@@ -126,6 +126,50 @@ export const FILTERS_RAIL_WIDTH = 280;
|
|
|
126
126
|
* `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
|
|
127
127
|
* height by default, and a stretched box has nothing to stick to.
|
|
128
128
|
*/
|
|
129
|
+
/** The class the rail's own scrollbar rules are hung on. */
|
|
130
|
+
export const RAIL_CLASS = "stapel-search-rail";
|
|
131
|
+
|
|
132
|
+
/** The `href` the hoisted rail sheet is deduplicated by. */
|
|
133
|
+
export const RAIL_STYLE_HREF = "stapel-search-rail";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The rail scrolls, and its scrollbar must not sit ON the filters.
|
|
137
|
+
*
|
|
138
|
+
* `scrollbar-width: thin` and `scrollbar-gutter: stable` (below, in `RAIL`)
|
|
139
|
+
* are the standard half of this and they are not enough: on every WebKit
|
|
140
|
+
* platform with overlay scrollbars — a Mac by default, every iOS browser —
|
|
141
|
+
* the bar is drawn OVER the content and the gutter reserves nothing, so the
|
|
142
|
+
* walker saw the bar lying across the right edge of the checkbox labels.
|
|
143
|
+
*
|
|
144
|
+
* So the rail also declares a CLASSIC scrollbar through the WebKit
|
|
145
|
+
* pseudo-elements: a bar with a real width, which pushes the panel's content
|
|
146
|
+
* in by exactly that much instead of floating above it, drawn in the token
|
|
147
|
+
* palette so it is the panel's own hairline in both themes rather than a
|
|
148
|
+
* hard-coded grey that glows in the dark one. `--stapel-*` custom properties
|
|
149
|
+
* resolve per theme at paint time, which is why this is a sheet and not a
|
|
150
|
+
* pair of computed inline values: an inline colour would freeze whichever
|
|
151
|
+
* theme was mounted first.
|
|
152
|
+
*
|
|
153
|
+
* Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
|
|
154
|
+
* pseudo-element is unreachable from an inline style — the same reason
|
|
155
|
+
* `<LocationSummaryLine>` hoists one.
|
|
156
|
+
*/
|
|
157
|
+
export function railScrollbarCss(): string {
|
|
158
|
+
const rail = `.${RAIL_CLASS}`;
|
|
159
|
+
return [
|
|
160
|
+
// A real width: an overlay bar occupies no space and therefore overlaps.
|
|
161
|
+
`${rail}::-webkit-scrollbar{inline-size:8px;block-size:8px}`,
|
|
162
|
+
`${rail}::-webkit-scrollbar-track{background:transparent}`,
|
|
163
|
+
`${rail}::-webkit-scrollbar-thumb{background:${cssVar("border")};` +
|
|
164
|
+
`border-radius:${cssVar("radius-full")}}`,
|
|
165
|
+
`${rail}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
|
|
166
|
+
// Firefox/Chromium's standard properties, stated here too so the rule
|
|
167
|
+
// travels with the class when the panel is used outside `<SearchPage>`.
|
|
168
|
+
`${rail}{scrollbar-width:thin;scrollbar-gutter:stable;` +
|
|
169
|
+
`scrollbar-color:${cssVar("border")} transparent}`,
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
129
173
|
const RAIL: CSSProperties = {
|
|
130
174
|
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
131
175
|
// Both bounds, not just the upper one. `flex-shrink: 0` already holds the
|
|
@@ -208,6 +252,14 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
208
252
|
* coordinate, with or without it.
|
|
209
253
|
*/
|
|
210
254
|
readonly geoLabel?: ReactNode;
|
|
255
|
+
/**
|
|
256
|
+
* The partition control, drawn at the top of the filter panel — see
|
|
257
|
+
* {@link FacetPanelPaneProps.partition}.
|
|
258
|
+
*/
|
|
259
|
+
readonly partition?: ReactNode;
|
|
260
|
+
/** Facet slugs pinned above every other group — see
|
|
261
|
+
* {@link FacetPanelPaneProps.pinnedFacets}. */
|
|
262
|
+
readonly pinnedFacets?: readonly string[];
|
|
211
263
|
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
212
264
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
213
265
|
readonly skippedNotice?: boolean;
|
|
@@ -639,7 +691,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
639
691
|
</>
|
|
640
692
|
) : showFilters ? (
|
|
641
693
|
<Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
|
|
642
|
-
<div style={RAIL}>
|
|
694
|
+
<div className={RAIL_CLASS} style={RAIL}>
|
|
695
|
+
{/* The rail's scrollbar, in the gutter and in the token palette —
|
|
696
|
+
see `railScrollbarCss`. Hoisted, deduped by `href`. */}
|
|
697
|
+
<style href={RAIL_STYLE_HREF} precedence="default">
|
|
698
|
+
{railScrollbarCss()}
|
|
699
|
+
</style>
|
|
700
|
+
{panel}
|
|
701
|
+
</div>
|
|
643
702
|
{/* ONE heading and ONE sort control. The page used to caption
|
|
644
703
|
the toolbar "Results" and then mount a pane whose own heading
|
|
645
704
|
says "Results" again — the live /s page printed both, one
|
package/src/default/index.ts
CHANGED
|
@@ -40,7 +40,12 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
// ── surfaces ────────────────────────────────────────────────────────────────
|
|
43
|
-
export {
|
|
43
|
+
export {
|
|
44
|
+
SearchPage,
|
|
45
|
+
RAIL_CLASS,
|
|
46
|
+
RAIL_STYLE_HREF,
|
|
47
|
+
railScrollbarCss,
|
|
48
|
+
} from "./SearchPage.js";
|
|
44
49
|
export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
|
|
45
50
|
|
|
46
51
|
export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
|
|
@@ -72,6 +77,8 @@ export {
|
|
|
72
77
|
FacetGroupControl,
|
|
73
78
|
facetGroupShape,
|
|
74
79
|
facetOptionNodes,
|
|
80
|
+
isDictionaryFacet,
|
|
81
|
+
FACET_DICTIONARY_THRESHOLD,
|
|
75
82
|
FACET_VISIBLE_OPTIONS,
|
|
76
83
|
} from "./FacetGroupControl.js";
|
|
77
84
|
export type {
|
|
@@ -80,7 +87,25 @@ export type {
|
|
|
80
87
|
FacetOptionNode,
|
|
81
88
|
} from "./FacetGroupControl.js";
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
// ── the browse surfaces a storefront PLACES (this pair does not lay them
|
|
91
|
+
// out: where a popular-values block or a partition row belongs on a
|
|
92
|
+
// category page is the page's decision) ──────────────────────────────────
|
|
93
|
+
export {
|
|
94
|
+
PopularValues,
|
|
95
|
+
popularOptions,
|
|
96
|
+
POPULAR_VALUES_COLUMNS,
|
|
97
|
+
POPULAR_VALUES_LIMIT,
|
|
98
|
+
} from "./PopularValues.js";
|
|
99
|
+
export type { PopularValuesProps } from "./PopularValues.js";
|
|
100
|
+
export { PartitionChips } from "./PartitionChips.js";
|
|
101
|
+
export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
FacetPanelPane,
|
|
105
|
+
FACET_OPEN_GROUPS,
|
|
106
|
+
FACET_SEARCH_THRESHOLD,
|
|
107
|
+
FACET_VISIBLE_GROUPS,
|
|
108
|
+
} from "./FacetPanelPane.js";
|
|
84
109
|
export type {
|
|
85
110
|
FacetPanelPaneProps,
|
|
86
111
|
CategoryFilterSlotProps,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* 4. the raw value.
|
|
33
33
|
*
|
|
34
34
|
* `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
|
|
35
|
-
* and it
|
|
35
|
+
* and it marks an option it could not name `labelSource: "none"` — which is
|
|
36
36
|
* what makes "did anyone name this?" answerable without a second lookup. Only
|
|
37
37
|
* those values are asked about, so the host is never called for a value the
|
|
38
38
|
* server or the schema already captioned, and a resolver that returns nothing
|
|
@@ -98,7 +98,7 @@ const NO_REQUESTS: readonly FacetLabelRequest[] = [];
|
|
|
98
98
|
/** The values of one group nobody has named — see the precedence note above. */
|
|
99
99
|
function unresolvedValues(group: FacetGroup): readonly string[] {
|
|
100
100
|
return group.options
|
|
101
|
-
.filter((option) => option.
|
|
101
|
+
.filter((option) => option.labelSource === "none")
|
|
102
102
|
.map((option) => option.value)
|
|
103
103
|
.sort((a, b) => a.localeCompare(b));
|
|
104
104
|
}
|
|
@@ -157,14 +157,14 @@ export function useHostFacetLabels(
|
|
|
157
157
|
return {
|
|
158
158
|
...group,
|
|
159
159
|
options: group.options.map((option) => {
|
|
160
|
-
// Precedence again, enforced rather than assumed: an option
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
if (option.
|
|
160
|
+
// Precedence again, enforced rather than assumed: an option the
|
|
161
|
+
// server or the schema already named is not the host's to
|
|
162
|
+
// overwrite. The source says so; the strings cannot.
|
|
163
|
+
if (option.labelSource !== "none") return option;
|
|
164
164
|
const caption = named[option.value];
|
|
165
165
|
return caption === undefined || caption.length === 0
|
|
166
166
|
? option
|
|
167
|
-
: { ...option, label: caption };
|
|
167
|
+
: { ...option, label: caption, labelSource: "host" as const };
|
|
168
168
|
}),
|
|
169
169
|
};
|
|
170
170
|
})
|