@stapel/search-react 0.22.0 → 0.24.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 +156 -0
- package/MODULE.md +12 -0
- package/README.md +122 -6
- package/dist/api/types.d.ts +13 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +22 -4
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +85 -9
- 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 +86 -2
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +210 -4
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +14 -0
- package/dist/default/PartitionChips.d.ts.map +1 -1
- package/dist/default/PartitionChips.js +30 -4
- package/dist/default/PartitionChips.js.map +1 -1
- 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 +65 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +47 -5
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +4 -4
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -3
- package/dist/default/index.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 +39 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -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 +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +64 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +144 -0
- 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/llms.txt +1 -1
- package/manifest.json +14 -1
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +13 -0
- package/src/default/FacetGroupControl.tsx +139 -14
- package/src/default/FacetPanelPane.tsx +131 -19
- package/src/default/FilterChips.tsx +313 -6
- package/src/default/PartitionChips.tsx +47 -4
- package/src/default/RangeFilterRow.tsx +116 -1
- package/src/default/SearchPage.tsx +94 -3
- package/src/default/index.ts +27 -3
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +50 -0
- package/src/i18n/ru.ts +9 -0
- package/src/index.ts +4 -0
- package/src/state/facets.ts +152 -0
- package/src/state/ranges.ts +44 -0
|
@@ -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
|
|
@@ -196,7 +240,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
196
240
|
* What the current category is CALLED — the chip's own text. The pair holds
|
|
197
241
|
* a path of slugs and no way to turn one into a catalogue name; absent, the
|
|
198
242
|
* chip states the path's last segment. See
|
|
199
|
-
* {@link
|
|
243
|
+
* {@link FilterChipsOpenerProps.categoryLabel}.
|
|
200
244
|
*/
|
|
201
245
|
readonly categoryLabel?: ReactNode;
|
|
202
246
|
/** The location control slot (`geo-react`). */
|
|
@@ -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;
|
|
@@ -253,6 +305,21 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
253
305
|
* describes the whole page, not the results column of it.
|
|
254
306
|
*/
|
|
255
307
|
readonly resultsHeader?: ReactNode;
|
|
308
|
+
/**
|
|
309
|
+
* Draw the APPLIED filter row in the results header — one chip per applied
|
|
310
|
+
* value and per applied range, each of which removes it
|
|
311
|
+
* (`<FilterChips mode="applied">`).
|
|
312
|
+
*
|
|
313
|
+
* `"desktop"` is the shape this exists for: where the rail is on screen a
|
|
314
|
+
* choice otherwise leaves no trace above the results and dropping one of
|
|
315
|
+
* two constraints means hunting its button back down the column, while on
|
|
316
|
+
* the phone the opener row below already states every applied filter on its
|
|
317
|
+
* own chips. `true` draws it in both layouts; omitted, nothing changes.
|
|
318
|
+
*
|
|
319
|
+
* It renders itself away when nothing is applied, so a host never has to
|
|
320
|
+
* ask.
|
|
321
|
+
*/
|
|
322
|
+
readonly appliedChips?: boolean | "desktop";
|
|
256
323
|
/** What this surface calls its result list. See
|
|
257
324
|
* {@link SearchResultsPaneProps.heading}. */
|
|
258
325
|
readonly resultsHeading?: ReactNode;
|
|
@@ -349,6 +416,7 @@ interface SearchPageBodyProps {
|
|
|
349
416
|
readonly footer?: ReactNode;
|
|
350
417
|
readonly filtersHeader?: ReactNode;
|
|
351
418
|
readonly resultsHeader?: ReactNode;
|
|
419
|
+
readonly appliedChips?: boolean | "desktop";
|
|
352
420
|
readonly resultsHeading?: ReactNode;
|
|
353
421
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
354
422
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
@@ -585,6 +653,20 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
585
653
|
<div data-testid="search-results-header">{props.resultsHeader}</div>
|
|
586
654
|
)}
|
|
587
655
|
|
|
656
|
+
{/* What the search is NARROWED to, above the results, each constraint
|
|
657
|
+
beside the control that drops it. Drawn in the same band as the
|
|
658
|
+
results header because that is where a host would otherwise hand-mount
|
|
659
|
+
it — and it draws nothing at all when nothing is applied. */}
|
|
660
|
+
{(props.appliedChips === true ||
|
|
661
|
+
(props.appliedChips === "desktop" && layout !== "sheet")) && (
|
|
662
|
+
<FilterChips
|
|
663
|
+
mode="applied"
|
|
664
|
+
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
665
|
+
{...(locale !== undefined ? { locale } : {})}
|
|
666
|
+
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
667
|
+
/>
|
|
668
|
+
)}
|
|
669
|
+
|
|
588
670
|
{showFilters && layout === "sheet" ? (
|
|
589
671
|
<>
|
|
590
672
|
{/* The phone's filter row. It REPLACES the full-width "Filters (3)"
|
|
@@ -639,7 +721,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
639
721
|
</>
|
|
640
722
|
) : showFilters ? (
|
|
641
723
|
<Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
|
|
642
|
-
<div style={RAIL}>
|
|
724
|
+
<div className={RAIL_CLASS} style={RAIL}>
|
|
725
|
+
{/* The rail's scrollbar, in the gutter and in the token palette —
|
|
726
|
+
see `railScrollbarCss`. Hoisted, deduped by `href`. */}
|
|
727
|
+
<style href={RAIL_STYLE_HREF} precedence="default">
|
|
728
|
+
{railScrollbarCss()}
|
|
729
|
+
</style>
|
|
730
|
+
{panel}
|
|
731
|
+
</div>
|
|
643
732
|
{/* ONE heading and ONE sort control. The page used to caption
|
|
644
733
|
the toolbar "Results" and then mount a pane whose own heading
|
|
645
734
|
says "Results" again — the live /s page printed both, one
|
|
@@ -673,6 +762,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
673
762
|
footer,
|
|
674
763
|
filtersHeader,
|
|
675
764
|
resultsHeader,
|
|
765
|
+
appliedChips,
|
|
676
766
|
resultsHeading,
|
|
677
767
|
degradationNotice,
|
|
678
768
|
filtersLayout,
|
|
@@ -707,6 +797,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
707
797
|
{...(footer !== undefined ? { footer } : {})}
|
|
708
798
|
{...(filtersHeader !== undefined ? { filtersHeader } : {})}
|
|
709
799
|
{...(resultsHeader !== undefined ? { resultsHeader } : {})}
|
|
800
|
+
{...(appliedChips !== undefined ? { appliedChips } : {})}
|
|
710
801
|
{...(resultsHeading !== undefined ? { resultsHeading } : {})}
|
|
711
802
|
{...(degradationNotice !== undefined ? { degradationNotice } : {})}
|
|
712
803
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
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";
|
|
@@ -56,12 +61,26 @@ export {
|
|
|
56
61
|
CHIP_ROW_CAP,
|
|
57
62
|
CHIP_ROW_CLASS,
|
|
58
63
|
CHIP_ROW_STYLE_HREF,
|
|
64
|
+
appliedChipTestId,
|
|
65
|
+
buildAppliedChips,
|
|
59
66
|
capChipRow,
|
|
60
67
|
categoryLeaf,
|
|
61
68
|
chipRowCss,
|
|
62
69
|
orderChipFilters,
|
|
70
|
+
rangeChipText,
|
|
71
|
+
rangeLabelSource,
|
|
72
|
+
} from "./FilterChips.js";
|
|
73
|
+
export type {
|
|
74
|
+
AppliedChip,
|
|
75
|
+
AppliedChipTarget,
|
|
76
|
+
ChipBand,
|
|
77
|
+
ChipSpec,
|
|
78
|
+
FilterChipsAppliedProps,
|
|
79
|
+
FilterChipsCommonProps,
|
|
80
|
+
FilterChipsMode,
|
|
81
|
+
FilterChipsOpenerProps,
|
|
82
|
+
FilterChipsProps,
|
|
63
83
|
} from "./FilterChips.js";
|
|
64
|
-
export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
|
|
65
84
|
export { EmptyExits, RADIUS_WIDEN_FACTOR, parentCategory } from "./EmptyExits.js";
|
|
66
85
|
export type { EmptyExitsProps } from "./EmptyExits.js";
|
|
67
86
|
|
|
@@ -95,7 +114,12 @@ export type { PopularValuesProps } from "./PopularValues.js";
|
|
|
95
114
|
export { PartitionChips } from "./PartitionChips.js";
|
|
96
115
|
export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
|
|
97
116
|
|
|
98
|
-
export {
|
|
117
|
+
export {
|
|
118
|
+
FacetPanelPane,
|
|
119
|
+
FACET_OPEN_GROUPS,
|
|
120
|
+
FACET_SEARCH_THRESHOLD,
|
|
121
|
+
FACET_VISIBLE_GROUPS,
|
|
122
|
+
} from "./FacetPanelPane.js";
|
|
99
123
|
export type {
|
|
100
124
|
FacetPanelPaneProps,
|
|
101
125
|
CategoryFilterSlotProps,
|
package/src/i18n/es.ts
CHANGED
|
@@ -96,6 +96,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
96
96
|
"search.facets.dictionary_search": "Buscar un valor",
|
|
97
97
|
"search.facets.dictionary_empty": "Ningún valor coincide con esto",
|
|
98
98
|
"search.facets.dictionary_chosen": "Elegido",
|
|
99
|
+
"search.facets.dictionary_any": "Cualquiera",
|
|
100
|
+
"search.facets.all_filters": "Todos los filtros ({count})",
|
|
101
|
+
"search.facets.range_bounds": "de {min} a {max}",
|
|
99
102
|
"search.facets.popular_all": "Todos",
|
|
100
103
|
"search.partition.all": "Todos",
|
|
101
104
|
"search.partition.label": "Sección",
|
|
@@ -116,6 +119,12 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
116
119
|
"search.filters.short": "Filtros",
|
|
117
120
|
"search.filters.chip_more": ", +{count}",
|
|
118
121
|
"search.filters.chips_overflow": "Más · {count}",
|
|
122
|
+
"search.filters.applied_label": "Filtros aplicados",
|
|
123
|
+
"search.filters.chip_pair": "{name}: {value}",
|
|
124
|
+
"search.filters.chip_remove": "Quitar el filtro {filter}",
|
|
125
|
+
"search.filters.chip_range_between": "de {from} a {to}",
|
|
126
|
+
"search.filters.chip_range_from": "desde {from}",
|
|
127
|
+
"search.filters.chip_range_to": "hasta {to}",
|
|
119
128
|
|
|
120
129
|
"search.empty.exits_title": "Prueba a ampliar la búsqueda",
|
|
121
130
|
"search.empty.up_a_level": "Subir un nivel",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -171,6 +171,28 @@ export const SEARCH_I18N_KEYS = {
|
|
|
171
171
|
/** Over the values already chosen, which stay visible whatever the box
|
|
172
172
|
* says — a filter a person cannot see is a filter they cannot remove. */
|
|
173
173
|
facetsDictionaryChosen: "search.facets.dictionary_chosen",
|
|
174
|
+
/**
|
|
175
|
+
* The closed dictionary FIELD with nothing chosen — "no constraint on this
|
|
176
|
+
* axis", the way a classified says it ("Any make", shortened to "Any").
|
|
177
|
+
*
|
|
178
|
+
* Not `facetsPopularAll` and not `partitionAll`, which are both the word
|
|
179
|
+
* "All": those two name a WIDER SET a person can move to, and this one
|
|
180
|
+
* names the absence of a narrowing. The three read alike in English and
|
|
181
|
+
* diverge in every language with gender or case.
|
|
182
|
+
*/
|
|
183
|
+
facetsDictionaryAny: "search.facets.dictionary_any",
|
|
184
|
+
/**
|
|
185
|
+
* The tail of the rail, folded: how many groups are behind one control.
|
|
186
|
+
* `{count}` is a number in parentheses, not a counted noun, so this is not
|
|
187
|
+
* a plural family — the same shape as `facetsClearAll`.
|
|
188
|
+
*/
|
|
189
|
+
facetsAllFilters: "search.facets.all_filters",
|
|
190
|
+
/**
|
|
191
|
+
* The bounds of a picker axis, said in words when what was typed is
|
|
192
|
+
* outside them — a year before the catalogue's own floor otherwise does
|
|
193
|
+
* nothing at all, silently.
|
|
194
|
+
*/
|
|
195
|
+
facetsRangeBounds: "search.facets.range_bounds",
|
|
174
196
|
/** The way from the "popular values" block into the whole control. The
|
|
175
197
|
* block is a shortcut over the busiest values, so the link has to say that
|
|
176
198
|
* the rest exist. */
|
|
@@ -210,6 +232,25 @@ export const SEARCH_I18N_KEYS = {
|
|
|
210
232
|
* `{count}` is how many chips it hides (D16). */
|
|
211
233
|
filtersChipOverflow: "search.filters.chips_overflow",
|
|
212
234
|
|
|
235
|
+
// ── the APPLIED row (`<FilterChips mode="applied">`) ─────────────────────
|
|
236
|
+
/** The accessible name of the applied row. Distinct from
|
|
237
|
+
* `filtersChipsLabel`: that row OPENS pickers, this one DROPS filters, and
|
|
238
|
+
* a screen reader announcing both as "Filters" states neither. */
|
|
239
|
+
filtersAppliedLabel: "search.filters.applied_label",
|
|
240
|
+
/** One applied chip's caption: the axis AND the value — "Brand: Bosch".
|
|
241
|
+
* A chip printing the value alone states a constraint without saying what
|
|
242
|
+
* it constrains, which beside a rail of a dozen axes names nothing. */
|
|
243
|
+
filtersChipPair: "search.filters.chip_pair",
|
|
244
|
+
/** The accessible name of an applied chip: pressing it REMOVES the filter,
|
|
245
|
+
* and the caption alone never says so. `{filter}` is the caption. */
|
|
246
|
+
filtersChipRemove: "search.filters.chip_remove",
|
|
247
|
+
/** A numeric constraint with both bounds, as one phrase. */
|
|
248
|
+
filtersChipRangeBetween: "search.filters.chip_range_between",
|
|
249
|
+
/** A lower bound alone. */
|
|
250
|
+
filtersChipRangeFrom: "search.filters.chip_range_from",
|
|
251
|
+
/** An upper bound alone. */
|
|
252
|
+
filtersChipRangeTo: "search.filters.chip_range_to",
|
|
253
|
+
|
|
213
254
|
// ── a partition: one template split by a value its children name ─────────
|
|
214
255
|
/**
|
|
215
256
|
* The first chip of a partition row — the parent itself, unnarrowed.
|
|
@@ -497,6 +538,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
497
538
|
"search.facets.dictionary_search": "Find a value",
|
|
498
539
|
"search.facets.dictionary_empty": "No value matches this",
|
|
499
540
|
"search.facets.dictionary_chosen": "Chosen",
|
|
541
|
+
"search.facets.dictionary_any": "Any",
|
|
542
|
+
"search.facets.all_filters": "All filters ({count})",
|
|
543
|
+
"search.facets.range_bounds": "from {min} to {max}",
|
|
500
544
|
"search.facets.popular_all": "All",
|
|
501
545
|
"search.partition.all": "All",
|
|
502
546
|
"search.partition.label": "Section",
|
|
@@ -517,6 +561,12 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
517
561
|
"search.filters.short": "Filters",
|
|
518
562
|
"search.filters.chip_more": ", +{count}",
|
|
519
563
|
"search.filters.chips_overflow": "More · {count}",
|
|
564
|
+
"search.filters.applied_label": "Applied filters",
|
|
565
|
+
"search.filters.chip_pair": "{name}: {value}",
|
|
566
|
+
"search.filters.chip_remove": "Remove filter {filter}",
|
|
567
|
+
"search.filters.chip_range_between": "from {from} to {to}",
|
|
568
|
+
"search.filters.chip_range_from": "from {from}",
|
|
569
|
+
"search.filters.chip_range_to": "up to {to}",
|
|
520
570
|
|
|
521
571
|
"search.empty.exits_title": "Try widening the search",
|
|
522
572
|
"search.empty.up_a_level": "Go up a level",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -125,6 +125,9 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
125
125
|
"search.facets.dictionary_search": "Найти значение",
|
|
126
126
|
"search.facets.dictionary_empty": "Такого значения здесь нет",
|
|
127
127
|
"search.facets.dictionary_chosen": "Выбрано",
|
|
128
|
+
"search.facets.dictionary_any": "Любая",
|
|
129
|
+
"search.facets.all_filters": "Все фильтры ({count})",
|
|
130
|
+
"search.facets.range_bounds": "от {min} до {max}",
|
|
128
131
|
"search.facets.popular_all": "Все",
|
|
129
132
|
"search.partition.all": "Все",
|
|
130
133
|
"search.partition.label": "Раздел",
|
|
@@ -151,6 +154,12 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
151
154
|
"search.filters.short": "Фильтры",
|
|
152
155
|
"search.filters.chip_more": ", +{count}",
|
|
153
156
|
"search.filters.chips_overflow": "Ещё {count}",
|
|
157
|
+
"search.filters.applied_label": "Выбранные фильтры",
|
|
158
|
+
"search.filters.chip_pair": "{name}: {value}",
|
|
159
|
+
"search.filters.chip_remove": "Убрать фильтр «{filter}»",
|
|
160
|
+
"search.filters.chip_range_between": "от {from} до {to}",
|
|
161
|
+
"search.filters.chip_range_from": "от {from}",
|
|
162
|
+
"search.filters.chip_range_to": "до {to}",
|
|
154
163
|
|
|
155
164
|
"search.empty.exits_title": "Попробуйте расширить поиск",
|
|
156
165
|
"search.empty.up_a_level": "Подняться на уровень выше",
|
package/src/index.ts
CHANGED
|
@@ -116,8 +116,11 @@ export type {
|
|
|
116
116
|
export {
|
|
117
117
|
FACETABLE_FEATURE_TYPES,
|
|
118
118
|
buildFacetGroups,
|
|
119
|
+
facetGroupHasEvidence,
|
|
120
|
+
facetGroupIsDrawable,
|
|
119
121
|
facetOptionLabel,
|
|
120
122
|
isFacetableFeature,
|
|
123
|
+
orderFacetGroupsBySchema,
|
|
121
124
|
} from "./state/facets.js";
|
|
122
125
|
export type {
|
|
123
126
|
BuildFacetGroupsInput,
|
|
@@ -134,6 +137,7 @@ export {
|
|
|
134
137
|
|
|
135
138
|
export {
|
|
136
139
|
RANGE_FEATURE_TYPES,
|
|
140
|
+
RANGE_PICKER_MAX_VALUES,
|
|
137
141
|
buildRangeGroups,
|
|
138
142
|
coreRangeLabelKey,
|
|
139
143
|
isRangeFeature,
|
package/src/state/facets.ts
CHANGED
|
@@ -96,6 +96,46 @@ function warnUnnamedGroup(slug: string): void {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Slugs already reported as undrawable — one warning per slug per page. */
|
|
100
|
+
const warnedUndrawable = new Set<string>();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* An axis that reached the panel with nothing to draw, said once, in
|
|
104
|
+
* development only.
|
|
105
|
+
*
|
|
106
|
+
* Measured on a live classified's cars branch: `make_ref_select`,
|
|
107
|
+
* `model` and `generation` are `ref_select` features whose config is a bare
|
|
108
|
+
* `optionsRef` pointer into a vocabulary — there is no option table in the
|
|
109
|
+
* schema and there never will be — so whenever the server's facet plan does
|
|
110
|
+
* not COUNT them there is nothing on the client to enumerate, and the group
|
|
111
|
+
* left the rail without a word while every `select`-typed comfort option
|
|
112
|
+
* (steering side, power steering, heating) drew its schema table and stayed.
|
|
113
|
+
* buyer's report was "I cannot pick a make".
|
|
114
|
+
*
|
|
115
|
+
* The panel still refuses to draw a heading over nothing — that is the right
|
|
116
|
+
* call — but the disappearance is a WIRING FAULT with two possible owners
|
|
117
|
+
* (the server's plan skipped a required axis, or the host threaded the wrong
|
|
118
|
+
* category's schema), and neither of them can see it from the page.
|
|
119
|
+
*/
|
|
120
|
+
function warnUndrawableGroup(group: FacetGroup): void {
|
|
121
|
+
const env = typeof process === "undefined" ? undefined : process.env;
|
|
122
|
+
if (env?.NODE_ENV === "production") return;
|
|
123
|
+
if (warnedUndrawable.has(group.slug)) return;
|
|
124
|
+
warnedUndrawable.add(group.slug);
|
|
125
|
+
console.warn(
|
|
126
|
+
`[search-react] facet group "${group.slug}" has no values to draw: the ` +
|
|
127
|
+
`answer did not count it${
|
|
128
|
+
group.feature === undefined
|
|
129
|
+
? " and the category schema passed to this page does not define it"
|
|
130
|
+
: " and its config carries a vocabulary pointer, not an option table"
|
|
131
|
+
}, so the group is not rendered.` +
|
|
132
|
+
(group.feature?.mandatory === true
|
|
133
|
+
? " The schema marks this axis REQUIRED — a buyer cannot narrow by a" +
|
|
134
|
+
" field every seller had to fill."
|
|
135
|
+
: "")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
99
139
|
/**
|
|
100
140
|
* Value types whose values are a BOUNDED OPTION SET — the only kind of
|
|
101
141
|
* feature a person can be offered as a filter.
|
|
@@ -222,6 +262,105 @@ export function orderFacetGroups(
|
|
|
222
262
|
return [...groups].sort(compareFacetsByEvidence);
|
|
223
263
|
}
|
|
224
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Does the ANSWER have evidence for this axis — at least one value some
|
|
267
|
+
* candidate actually carries?
|
|
268
|
+
*
|
|
269
|
+
* The one fact that outranks every other rule in this module. A bucket with a
|
|
270
|
+
* count above zero is the server saying "documents in this result set have
|
|
271
|
+
* this value", and no schema opinion, no missing feature list and no type
|
|
272
|
+
* table may take an axis like that off the screen.
|
|
273
|
+
*/
|
|
274
|
+
export function facetGroupHasEvidence(group: FacetGroup): boolean {
|
|
275
|
+
return group.options.some((option) => (option.count ?? 0) > 0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Is there anything for a surface to DRAW here?
|
|
280
|
+
*
|
|
281
|
+
* Shared by the rail and the chip row, which each used to hold their own
|
|
282
|
+
* `options.length > 0` — one predicate, or the two surfaces drift into two
|
|
283
|
+
* opinions about what an empty group is.
|
|
284
|
+
*
|
|
285
|
+
* A group with no options is a heading over nothing: after
|
|
286
|
+
* {@link buildFacetGroups} learned to read the schema, what is left in that
|
|
287
|
+
* state is a `ref_select` whose config is a bare `optionsRef` pointer and
|
|
288
|
+
* which the server did not count — nothing to enumerate from either side. It
|
|
289
|
+
* is not drawn, and in development it is NAMED: a required axis (the make on
|
|
290
|
+
* cars leaf) disappearing out of a rail is exactly the fault this pair spent
|
|
291
|
+
* a release chasing, and it must not disappear silently a second time.
|
|
292
|
+
*
|
|
293
|
+
* A group the reader has already FILTERED on is drawn whatever its options
|
|
294
|
+
* say — a constraint with no control to remove it is worse than a bare
|
|
295
|
+
* heading.
|
|
296
|
+
*/
|
|
297
|
+
export function facetGroupIsDrawable(group: FacetGroup): boolean {
|
|
298
|
+
if (group.selected.length > 0) return true;
|
|
299
|
+
if (group.options.length > 0) return true;
|
|
300
|
+
warnUndrawableGroup(group);
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The order the RAIL puts groups in: the category's own schema order, with
|
|
306
|
+
* the axes the schema calls required in front of it.
|
|
307
|
+
*
|
|
308
|
+
* Evidence order ({@link compareFacetsByEvidence}) answers "which axis does
|
|
309
|
+
* this corpus fill in", which is the right question for a chip row that has
|
|
310
|
+
* room for four. It is the wrong question for a rail: on a cars leaf with
|
|
311
|
+
* three listings the busiest axis is whichever three values happen to be
|
|
312
|
+
* counted, so the rail opened on condition and colour while make, model and
|
|
313
|
+
* year — the three fields the schema marks
|
|
314
|
+
* `mandatory`, i.e. the three every seller had to fill and every buyer
|
|
315
|
+
* narrows by first — sat below them or off the fold entirely.
|
|
316
|
+
*
|
|
317
|
+
* So: the schema's own order, which is the order the composer asks the
|
|
318
|
+
* seller to fill the form in, with required first. Four bands:
|
|
319
|
+
*
|
|
320
|
+
* 1. `pinned` slugs, in the order given — the axis a page has already
|
|
321
|
+
* decided is its subject (a partition's own field).
|
|
322
|
+
* 2. schema-required (`mandatory: true`), in schema order.
|
|
323
|
+
* 3. everything else the schema names, in schema order.
|
|
324
|
+
* 4. what the schema does not name at all — including EVERY group when the
|
|
325
|
+
* host passed no feature list, which is the live parent-node case — in
|
|
326
|
+
* evidence order, because with no schema there is no other order to have.
|
|
327
|
+
*
|
|
328
|
+
* Stable: within a band the comparator falls through to evidence and then to
|
|
329
|
+
* the order `buildFacetGroups` gave, so equal-ranked groups never reshuffle.
|
|
330
|
+
*/
|
|
331
|
+
export function orderFacetGroupsBySchema(input: {
|
|
332
|
+
readonly groups: readonly FacetGroup[];
|
|
333
|
+
/** The category schema, in the order the category declares it. */
|
|
334
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
335
|
+
/** Slugs pinned above everything, in the order given. */
|
|
336
|
+
readonly pinned?: readonly string[];
|
|
337
|
+
}): readonly FacetGroup[] {
|
|
338
|
+
const schemaIndex = new Map<string, number>();
|
|
339
|
+
(input.categoryFeatures ?? []).forEach((feature, index) => {
|
|
340
|
+
if (!schemaIndex.has(feature.slug)) schemaIndex.set(feature.slug, index);
|
|
341
|
+
});
|
|
342
|
+
const pinnedIndex = new Map<string, number>();
|
|
343
|
+
(input.pinned ?? []).forEach((slug, index) => {
|
|
344
|
+
if (!pinnedIndex.has(slug)) pinnedIndex.set(slug, index);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const band = (group: FacetGroup): number => {
|
|
348
|
+
if (pinnedIndex.has(group.slug)) return 0;
|
|
349
|
+
if (!schemaIndex.has(group.slug)) return 3;
|
|
350
|
+
return group.feature?.mandatory === true ? 1 : 2;
|
|
351
|
+
};
|
|
352
|
+
const within = (group: FacetGroup): number =>
|
|
353
|
+
pinnedIndex.get(group.slug) ?? schemaIndex.get(group.slug) ?? 0;
|
|
354
|
+
|
|
355
|
+
return [...input.groups].sort((a, b) => {
|
|
356
|
+
const byBand = band(a) - band(b);
|
|
357
|
+
if (byBand !== 0) return byBand;
|
|
358
|
+
const byOrder = within(a) - within(b);
|
|
359
|
+
if (byOrder !== 0) return byOrder;
|
|
360
|
+
return compareFacetsByEvidence(a, b);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
225
364
|
export interface BuildFacetGroupsInput {
|
|
226
365
|
/** The envelope's `facets`: `{slug: {value: count}}`. */
|
|
227
366
|
readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
|
@@ -467,6 +606,19 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
467
606
|
seen.add(slug);
|
|
468
607
|
// Applied first, type second — in that order, so an `imei` somebody
|
|
469
608
|
// somehow got into a link keeps the control that removes it.
|
|
609
|
+
//
|
|
610
|
+
// EVIDENCE does not enter here, and the reason is worth stating because
|
|
611
|
+
// the opposite was tried: a counted bucket cannot promote a slug the
|
|
612
|
+
// schema NAMES AND DISOWNS. An `imei` the engine counted is still not a
|
|
613
|
+
// filter — a free-text identifier enumerates one term per document — and
|
|
614
|
+
// a `visibility: "owner"` feature is one the canon says is never
|
|
615
|
+
// facetable at all. What evidence does outrank is SILENCE: an absent def,
|
|
616
|
+
// an untyped def, and the whole empty feature list the live cars page
|
|
617
|
+
// passes at its parent node all answer "not a verdict"
|
|
618
|
+
// (see {@link isFacetableFeature}), so a counted axis is never dropped
|
|
619
|
+
// for a schema that says nothing about it. That is the live case; a
|
|
620
|
+
// wrong-schema case where some other category types `make_ref_select` as
|
|
621
|
+
// free text is not one this pair can tell apart from a real `imei`.
|
|
470
622
|
const applied = (input.state.filters[slug] ?? []).length > 0;
|
|
471
623
|
if (!applied && !isFacetableFeature(bySlug.get(slug))) continue;
|
|
472
624
|
slugs.push(slug);
|
package/src/state/ranges.ts
CHANGED
|
@@ -76,6 +76,19 @@ export interface RangeGroup {
|
|
|
76
76
|
* category forgot to declare an attribute for it.
|
|
77
77
|
*/
|
|
78
78
|
readonly core: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* The values this axis can take, when it is a BOUNDED INTEGER small enough
|
|
81
|
+
* to pick from — `undefined` for every other axis, including price.
|
|
82
|
+
*
|
|
83
|
+
* A year is not a number a person types, it is one of a hundred-odd values,
|
|
84
|
+
* and the reference classified draws it as two pickers. `min`/`max` from
|
|
85
|
+
* the schema (`year: 1900..2027` on a live cars leaf) bound the list;
|
|
86
|
+
* {@link RANGE_PICKER_MAX_VALUES} is where a picker stops being one and
|
|
87
|
+
* becomes a scroll with a search box in it, at which point two typed fields
|
|
88
|
+
* are the better control. Descending, because the busy end of a bounded
|
|
89
|
+
* axis is its top: a year picker that opens on 1900 is a picker nobody uses.
|
|
90
|
+
*/
|
|
91
|
+
readonly picker: readonly number[] | undefined;
|
|
79
92
|
/**
|
|
80
93
|
* ISO 4217 code when the row is money, so the control can read as money
|
|
81
94
|
* instead of as a bare integer. Only ever set on a core axis: an attribute
|
|
@@ -131,6 +144,36 @@ function translate(t: ((key: string) => string) | undefined, key: string): strin
|
|
|
131
144
|
return resolved.length > 0 ? resolved : key;
|
|
132
145
|
}
|
|
133
146
|
|
|
147
|
+
/**
|
|
148
|
+
* How many values a bounded integer may have and still be a PICKER.
|
|
149
|
+
*
|
|
150
|
+
* 300 covers every year range a catalogue declares (a live cars leaf is
|
|
151
|
+
* 1900..2027, 128 values) and a door count, a seat count, a floor number,
|
|
152
|
+
* and stops well short of a mileage (1..1000000), which is a number people
|
|
153
|
+
* type and never a list.
|
|
154
|
+
*/
|
|
155
|
+
export const RANGE_PICKER_MAX_VALUES = 300;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The value list for a bounded integer axis, or `undefined`.
|
|
159
|
+
*
|
|
160
|
+
* A CORE axis never gets one: price is unbounded by construction and the
|
|
161
|
+
* server does not declare bounds for it.
|
|
162
|
+
*/
|
|
163
|
+
function pickerValues(
|
|
164
|
+
feature: FeatureDef | undefined,
|
|
165
|
+
min: number | undefined,
|
|
166
|
+
max: number | undefined
|
|
167
|
+
): readonly number[] | undefined {
|
|
168
|
+
if (feature === undefined) return undefined;
|
|
169
|
+
if (featureType(feature) !== "int") return undefined;
|
|
170
|
+
if (min === undefined || max === undefined) return undefined;
|
|
171
|
+
if (!Number.isInteger(min) || !Number.isInteger(max)) return undefined;
|
|
172
|
+
const span = max - min + 1;
|
|
173
|
+
if (span < 2 || span > RANGE_PICKER_MAX_VALUES) return undefined;
|
|
174
|
+
return Array.from({ length: span }, (_, i) => max - i);
|
|
175
|
+
}
|
|
176
|
+
|
|
134
177
|
/** Is this feature one a numeric range row is drawn for? */
|
|
135
178
|
export function isRangeFeature(feature: FeatureDef): boolean {
|
|
136
179
|
const type = featureType(feature);
|
|
@@ -190,6 +233,7 @@ export function buildRangeGroups(
|
|
|
190
233
|
? undefined
|
|
191
234
|
: (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
|
|
192
235
|
step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
|
|
236
|
+
picker: isCore ? undefined : pickerValues(feature, num(config["min"]), num(config["max"])),
|
|
193
237
|
active: applied !== undefined,
|
|
194
238
|
core: isCore,
|
|
195
239
|
currency: isCore ? str(input.currency) : undefined,
|