@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
package/src/api/types.ts
CHANGED
|
@@ -30,6 +30,19 @@ export type Schemas = components["schemas"];
|
|
|
30
30
|
*/
|
|
31
31
|
export type FacetLabels = Schemas["FacetLabels"] & {
|
|
32
32
|
readonly label?: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Whether {@link label} is a translation KEY rather than a caption, the way
|
|
35
|
+
* `translatable` says it for the values.
|
|
36
|
+
*
|
|
37
|
+
* Measured on a live answer (`category=141/151`, 2026-09-04): the server
|
|
38
|
+
* sends it beside every label — a make caption with
|
|
39
|
+
* `label_translatable: false`, a condition caption with `true` — and the
|
|
40
|
+
* describe it. Declared, not consumed: this pair passes the group heading
|
|
41
|
+
* through its translator either way, and a caption that is not a key comes
|
|
42
|
+
* back unchanged (`translate()` keeps a key it cannot resolve). Typed so a
|
|
43
|
+
* fixture captured from the wire is not a type error.
|
|
44
|
+
*/
|
|
45
|
+
readonly label_translatable?: boolean;
|
|
33
46
|
};
|
|
34
47
|
|
|
35
48
|
/** `facet_labels` as a whole: `{slug: {label, translatable, values}}`. */
|
|
@@ -66,7 +66,7 @@ import { useState } from "react";
|
|
|
66
66
|
import type { CSSProperties, ReactElement } from "react";
|
|
67
67
|
import { Button, Checkbox, Flex, Input, Typography } from "antd";
|
|
68
68
|
import { useT } from "@stapel/core";
|
|
69
|
-
import { radii, spacing } from "@stapel/tokens";
|
|
69
|
+
import { controls, cssVar, radii, spacing } from "@stapel/tokens";
|
|
70
70
|
import {
|
|
71
71
|
VOCABULARY_BACKED_TYPES,
|
|
72
72
|
featureConfig,
|
|
@@ -181,18 +181,28 @@ export function isDictionaryFacet(group: FacetGroup): boolean {
|
|
|
181
181
|
/**
|
|
182
182
|
* Which of the four shapes a group takes.
|
|
183
183
|
*
|
|
184
|
-
* Order matters
|
|
185
|
-
*
|
|
186
|
-
* is
|
|
187
|
-
* the
|
|
184
|
+
* Order matters, and it changed in one place after a live measurement:
|
|
185
|
+
*
|
|
186
|
+
* - a hierarchical facet is nested even when it is single-choice, because
|
|
187
|
+
* losing the tree costs more than losing the pills;
|
|
188
|
+
* - a DICTIONARY outranks the pills. The make axis on the live cars leaf
|
|
189
|
+
* `maxSelected: 1` over a 418-value vocabulary, so "pick one" won and the
|
|
190
|
+
* control it produced was four hundred pills in a 280px rail — a wall
|
|
191
|
+
* with a different border radius. Above the fold the shape a person needs
|
|
192
|
+
* is a search box, whether or not they may tick two; below it,
|
|
193
|
+
* single-choice still means pills, because `isDictionaryFacet` requires
|
|
194
|
+
* more than {@link FACET_DICTIONARY_THRESHOLD} counted buckets;
|
|
195
|
+
* - and a dictionary is a dictionary before it is a checkbox list, because
|
|
196
|
+
* the checkbox list is the shape it was drawn as when nobody could pick a
|
|
197
|
+
* make.
|
|
188
198
|
*/
|
|
189
199
|
export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
190
200
|
const feature = group.feature;
|
|
191
201
|
if (feature !== undefined && featureType(feature) === "hierarchical_select") {
|
|
192
202
|
return "nested";
|
|
193
203
|
}
|
|
194
|
-
if (
|
|
195
|
-
return
|
|
204
|
+
if (isDictionaryFacet(group)) return "dictionary";
|
|
205
|
+
return singleChoice(feature) ? "segmented" : "checkbox";
|
|
196
206
|
}
|
|
197
207
|
|
|
198
208
|
/**
|
|
@@ -519,6 +529,105 @@ function DictionaryBody(props: {
|
|
|
519
529
|
);
|
|
520
530
|
}
|
|
521
531
|
|
|
532
|
+
/**
|
|
533
|
+
* The closed face of a dictionary group on DESKTOP: a select-shaped field
|
|
534
|
+
* that reads what is chosen, or "Any".
|
|
535
|
+
*
|
|
536
|
+
* A 418-value vocabulary rendered as a permanently-open box plus a scrolling
|
|
537
|
+
* list is right in a phone sheet, where the sheet IS the disclosure and there
|
|
538
|
+
* is one group on screen. In a 280px rail it is the whole rail: the reference
|
|
539
|
+
* classified draws the make as a field reading "Any" that opens the
|
|
540
|
+
* searchable list, and every axis under it stays reachable at a glance.
|
|
541
|
+
*
|
|
542
|
+
* A native `<button role="combobox">` rather than antd's `Select`, for the
|
|
543
|
+
* same reason the option pills are native buttons: the list underneath is
|
|
544
|
+
* this component's — it carries per-option counts, a chosen block and a fold
|
|
545
|
+
* — and a `Select` that only lends its trigger is a dependency on a popup
|
|
546
|
+
* layer for a border. `aria-expanded` on a real button is the disclosure
|
|
547
|
+
* pattern; Escape closes, and the field keeps focus so the next Tab goes
|
|
548
|
+
* where the person expects.
|
|
549
|
+
*/
|
|
550
|
+
const DICTIONARY_FIELD: CSSProperties = {
|
|
551
|
+
display: "flex",
|
|
552
|
+
alignItems: "center",
|
|
553
|
+
gap: spacing[1],
|
|
554
|
+
inlineSize: "100%",
|
|
555
|
+
minBlockSize: controls.height,
|
|
556
|
+
paddingInline: spacing[2],
|
|
557
|
+
paddingBlock: spacing[1],
|
|
558
|
+
border: `1px solid ${cssVar("border")}`,
|
|
559
|
+
borderRadius: cssVar("radius-md"),
|
|
560
|
+
background: cssVar("surface"),
|
|
561
|
+
color: "inherit",
|
|
562
|
+
font: "inherit",
|
|
563
|
+
textAlign: "start",
|
|
564
|
+
cursor: "pointer",
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/** The chosen values, or the word for "no constraint on this axis". Never a
|
|
568
|
+
* count: "3 chosen" makes a person open the field to find out which three. */
|
|
569
|
+
const DICTIONARY_FIELD_TEXT: CSSProperties = {
|
|
570
|
+
flex: "1 1 auto",
|
|
571
|
+
minInlineSize: 0,
|
|
572
|
+
overflow: "hidden",
|
|
573
|
+
textOverflow: "ellipsis",
|
|
574
|
+
whiteSpace: "nowrap",
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
function DictionaryField(props: {
|
|
578
|
+
readonly group: FacetGroup;
|
|
579
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
580
|
+
readonly visible: number;
|
|
581
|
+
}): ReactElement {
|
|
582
|
+
const t = useT();
|
|
583
|
+
const { group } = props;
|
|
584
|
+
const [open, setOpen] = useState(false);
|
|
585
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
586
|
+
const text =
|
|
587
|
+
chosen.length > 0
|
|
588
|
+
? chosen.map((option) => option.label).join(", ")
|
|
589
|
+
: t(SEARCH_I18N_KEYS.facetsDictionaryAny);
|
|
590
|
+
return (
|
|
591
|
+
<Flex vertical gap={spacing[1]}>
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
role="combobox"
|
|
595
|
+
aria-expanded={open}
|
|
596
|
+
aria-haspopup="listbox"
|
|
597
|
+
aria-label={group.label}
|
|
598
|
+
style={DICTIONARY_FIELD}
|
|
599
|
+
data-testid={`facet-dictionary-field-${group.slug}`}
|
|
600
|
+
data-chosen={chosen.length}
|
|
601
|
+
data-analytics="none"
|
|
602
|
+
data-analytics-reason="opening a filter group is a read, not a flow step"
|
|
603
|
+
onKeyDown={(event) => {
|
|
604
|
+
if (event.key === "Escape" && open) {
|
|
605
|
+
event.preventDefault();
|
|
606
|
+
setOpen(false);
|
|
607
|
+
}
|
|
608
|
+
if (event.key === "ArrowDown" && !open) {
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
setOpen(true);
|
|
611
|
+
}
|
|
612
|
+
}}
|
|
613
|
+
onClick={() => {
|
|
614
|
+
setOpen((was) => !was);
|
|
615
|
+
}}
|
|
616
|
+
>
|
|
617
|
+
<span style={DICTIONARY_FIELD_TEXT}>{text}</span>
|
|
618
|
+
<ChevronGlyph open={open} />
|
|
619
|
+
</button>
|
|
620
|
+
{open && (
|
|
621
|
+
<DictionaryBody
|
|
622
|
+
group={group}
|
|
623
|
+
onToggle={props.onToggle}
|
|
624
|
+
visible={props.visible}
|
|
625
|
+
/>
|
|
626
|
+
)}
|
|
627
|
+
</Flex>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
522
631
|
export interface FacetGroupControlProps {
|
|
523
632
|
readonly group: FacetGroup;
|
|
524
633
|
readonly onToggle: (slug: string, value: string) => void;
|
|
@@ -538,6 +647,14 @@ export interface FacetGroupControlProps {
|
|
|
538
647
|
/** Whether a `collapsible` group STARTS open. Default `true`. The initial
|
|
539
648
|
* value only — the person owns the state after the first click. */
|
|
540
649
|
readonly defaultOpen?: boolean;
|
|
650
|
+
/**
|
|
651
|
+
* How a `"dictionary"` group is drawn. `"field"` is the desktop shape — a
|
|
652
|
+
* select-style field reading its chosen values or "Any", which opens the
|
|
653
|
+
* searchable list; `"inline"` (the default) keeps the list open, the shape
|
|
654
|
+
* a phone sheet wants because the sheet is already the disclosure.
|
|
655
|
+
* Meaningless for the other three shapes.
|
|
656
|
+
*/
|
|
657
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
541
658
|
}
|
|
542
659
|
|
|
543
660
|
export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
@@ -618,13 +735,21 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
618
735
|
{/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
|
|
619
736
|
checkboxes are still a hundred stops for a screen reader, and the
|
|
620
737
|
measured rail held 118 of them. */}
|
|
621
|
-
{open &&
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
738
|
+
{open &&
|
|
739
|
+
shape === "dictionary" &&
|
|
740
|
+
(props.dictionaryMode === "field" ? (
|
|
741
|
+
<DictionaryField
|
|
742
|
+
group={group}
|
|
743
|
+
onToggle={props.onToggle}
|
|
744
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
745
|
+
/>
|
|
746
|
+
) : (
|
|
747
|
+
<DictionaryBody
|
|
748
|
+
group={group}
|
|
749
|
+
onToggle={props.onToggle}
|
|
750
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
751
|
+
/>
|
|
752
|
+
))}
|
|
628
753
|
|
|
629
754
|
{open && shape !== "dictionary" && (
|
|
630
755
|
<>
|
|
@@ -95,7 +95,11 @@ import type { FacetPanelBag } from "../headless/FacetPanel.js";
|
|
|
95
95
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
96
96
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
97
97
|
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
facetCoverage,
|
|
100
|
+
facetGroupIsDrawable,
|
|
101
|
+
orderFacetGroupsBySchema,
|
|
102
|
+
} from "../state/facets.js";
|
|
99
103
|
import type { FacetGroup } from "../state/facets.js";
|
|
100
104
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
101
105
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
@@ -176,6 +180,17 @@ export const FACET_OPEN_GROUPS = 5;
|
|
|
176
180
|
*/
|
|
177
181
|
export const FACET_SEARCH_THRESHOLD = 6;
|
|
178
182
|
|
|
183
|
+
/**
|
|
184
|
+
* How many groups the rail draws before the rest go behind one control.
|
|
185
|
+
*
|
|
186
|
+
* The reference classified shows a make, a price, a year and a handful of
|
|
187
|
+
* body axes, and then the word "all filters" — eight is the count that fills
|
|
188
|
+
* a 900px rail once the partition row and the price have taken their share,
|
|
189
|
+
* and it is the point past which a person is scanning rather than reading.
|
|
190
|
+
* The tail is not hidden: `facetsAllFilters` names how many are in it.
|
|
191
|
+
*/
|
|
192
|
+
export const FACET_VISIBLE_GROUPS = 8;
|
|
193
|
+
|
|
179
194
|
/** What a host's category control is handed. */
|
|
180
195
|
export interface CategoryFilterSlotProps {
|
|
181
196
|
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
@@ -256,6 +271,37 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
256
271
|
* count-bearing bar above that one would be the same sentence twice.
|
|
257
272
|
*/
|
|
258
273
|
readonly footerBar?: boolean;
|
|
274
|
+
/**
|
|
275
|
+
* The partition control, drawn at the TOP of the panel — above the price,
|
|
276
|
+
* above every facet.
|
|
277
|
+
*
|
|
278
|
+
* A partition (`children_as: "chips"`) is not a filter among filters: it is
|
|
279
|
+
* which of one template's halves the page is about, and the reference
|
|
280
|
+
* classified puts it first for that reason (a car-type row: all, used,
|
|
281
|
+
* new). It is a slot rather than a component because the
|
|
282
|
+
* children come from the catalogue tree, which is `categories-react`'s;
|
|
283
|
+
* `<PartitionChips variant="segmented">` is what a host usually puts here.
|
|
284
|
+
*/
|
|
285
|
+
readonly partition?: ReactNode;
|
|
286
|
+
/**
|
|
287
|
+
* Slugs pinned above every other group, in the order given — the axis a
|
|
288
|
+
* page has already decided is its subject. See
|
|
289
|
+
* {@link orderFacetGroupsBySchema}.
|
|
290
|
+
*/
|
|
291
|
+
readonly pinnedFacets?: readonly string[];
|
|
292
|
+
/**
|
|
293
|
+
* How many groups before the tail folds under "All filters (K)". Default
|
|
294
|
+
* {@link FACET_VISIBLE_GROUPS}; `null` draws every group, which is what a
|
|
295
|
+
* phone sheet devoted to filtering wants.
|
|
296
|
+
*/
|
|
297
|
+
readonly visibleGroups?: number | null;
|
|
298
|
+
/**
|
|
299
|
+
* How a DICTIONARY group is drawn. `"field"` is the desktop shape — a
|
|
300
|
+
* select-style field reading its chosen values or "Any", which opens the
|
|
301
|
+
* searchable list; `"inline"` (the default) keeps the list open, which is
|
|
302
|
+
* the phone sheet's shape because a sheet is already a disclosure.
|
|
303
|
+
*/
|
|
304
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
259
305
|
}
|
|
260
306
|
|
|
261
307
|
/**
|
|
@@ -463,6 +509,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
463
509
|
// panel is drawn, never what the search is, so it must not survive into a
|
|
464
510
|
// shared link the way everything in `useSearchState` does.
|
|
465
511
|
const [filterQuery, setFilterQuery] = useState("");
|
|
512
|
+
// Whether the tail past `visibleGroups` is open. Presentation, like the
|
|
513
|
+
// panel's own search box: the URL is the search, and how much of the rail a
|
|
514
|
+
// person has unfolded is not part of it.
|
|
515
|
+
const [tailOpen, setTailOpen] = useState(false);
|
|
466
516
|
|
|
467
517
|
return (
|
|
468
518
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -540,6 +590,12 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
540
590
|
)}
|
|
541
591
|
</Flex>
|
|
542
592
|
|
|
593
|
+
{/* The partition first: which half of one template this page is
|
|
594
|
+
about is not a filter among filters. */}
|
|
595
|
+
{props.partition !== undefined && (
|
|
596
|
+
<div data-testid="search-partition">{props.partition}</div>
|
|
597
|
+
)}
|
|
598
|
+
|
|
543
599
|
<CategoryFilter
|
|
544
600
|
{...(props.renderCategoryFilter !== undefined
|
|
545
601
|
? { render: props.renderCategoryFilter }
|
|
@@ -617,25 +673,34 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
617
673
|
// case: a `ref_select` whose config is a bare pointer into
|
|
618
674
|
// a vocabulary this pair cannot read. A heading with no
|
|
619
675
|
// control under it names nothing, so it is not drawn.
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
676
|
+
// SCHEMA order, required first — see
|
|
677
|
+
// `orderFacetGroupsBySchema`. The rail ranked by evidence for
|
|
678
|
+
// two releases, which on a three-listing cars leaf put
|
|
679
|
+
// condition and colour above make, model and year:
|
|
680
|
+
// the busiest axis is the right question for a chip row with
|
|
681
|
+
// room for four and the wrong one for the column a person
|
|
682
|
+
// narrows a catalogue in. Groups the schema does not name
|
|
683
|
+
// keep evidence order among themselves.
|
|
684
|
+
const drawable = orderFacetGroupsBySchema({
|
|
685
|
+
groups: groups.filter(facetGroupIsDrawable),
|
|
686
|
+
...(props.categoryFeatures !== undefined
|
|
687
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
688
|
+
: {}),
|
|
689
|
+
...(props.pinnedFacets !== undefined
|
|
690
|
+
? { pinned: props.pinnedFacets }
|
|
691
|
+
: {}),
|
|
692
|
+
});
|
|
627
693
|
// Which groups OPEN — see the module note. Chosen groups are
|
|
628
|
-
// open unconditionally below;
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
694
|
+
// open unconditionally below; the rest are the first
|
|
695
|
+
// FACET_OPEN_GROUPS of the order above, so the panel's first
|
|
696
|
+
// screen is the axes the category itself calls required. A
|
|
697
|
+
// group the server never counted starts as a header, which is
|
|
698
|
+
// what keeps the wall of "not counted" rows folded.
|
|
699
|
+
const openByOrder = new Set(
|
|
633
700
|
drawable
|
|
634
|
-
.filter((group) => group.counted)
|
|
635
|
-
.map((group) => [group, facetCoverage(group)] as const)
|
|
636
|
-
.sort((a, b) => b[1] - a[1])
|
|
701
|
+
.filter((group) => group.counted || facetCoverage(group) > 0)
|
|
637
702
|
.slice(0, FACET_OPEN_GROUPS)
|
|
638
|
-
.map((
|
|
703
|
+
.map((group) => group.slug)
|
|
639
704
|
);
|
|
640
705
|
const searchable = drawable.length >= FACET_SEARCH_THRESHOLD;
|
|
641
706
|
const needle = searchable
|
|
@@ -648,6 +713,33 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
648
713
|
);
|
|
649
714
|
const listed =
|
|
650
715
|
needle === "" ? drawable : drawable.filter(matches);
|
|
716
|
+
// The tail. Only while nothing is typed: a query has already
|
|
717
|
+
// narrowed the list, and folding its answer would hide the
|
|
718
|
+
// thing that was looked for. One group over the limit is not
|
|
719
|
+
// folded — a control that reveals exactly one heading costs
|
|
720
|
+
// more than it saves.
|
|
721
|
+
const groupLimit =
|
|
722
|
+
props.visibleGroups === null
|
|
723
|
+
? null
|
|
724
|
+
: (props.visibleGroups ?? FACET_VISIBLE_GROUPS);
|
|
725
|
+
const tailFolded =
|
|
726
|
+
groupLimit !== null &&
|
|
727
|
+
needle === "" &&
|
|
728
|
+
listed.length > groupLimit + 1;
|
|
729
|
+
// A CONSTRAINT NEVER FOLDS. The fold hides axes a person has
|
|
730
|
+
// not touched; a group they have chosen a value in stays in
|
|
731
|
+
// the visible band wherever the schema put it, because the
|
|
732
|
+
// control that removes a filter is the one they came back
|
|
733
|
+
// for. (The rail used to rank answered axes to the top for
|
|
734
|
+
// this; schema order is stable under a click, which a rail
|
|
735
|
+
// that reshuffles as you tick is not.)
|
|
736
|
+
const shownGroups =
|
|
737
|
+
tailFolded && !tailOpen && groupLimit !== null
|
|
738
|
+
? listed.filter(
|
|
739
|
+
(group, index) =>
|
|
740
|
+
index < groupLimit || group.selected.length > 0
|
|
741
|
+
)
|
|
742
|
+
: listed;
|
|
651
743
|
return (
|
|
652
744
|
<Flex vertical gap={spacing[4]}>
|
|
653
745
|
{searchable && (
|
|
@@ -684,7 +776,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
684
776
|
a closed header is not an answer. Remounting is the
|
|
685
777
|
honest way to re-ask the question; the person's own
|
|
686
778
|
opens and closes come back when the box clears. */}
|
|
687
|
-
{
|
|
779
|
+
{shownGroups.map((group) => (
|
|
688
780
|
<FacetGroupControl
|
|
689
781
|
key={needle === "" ? group.slug : `${group.slug}:match`}
|
|
690
782
|
group={group}
|
|
@@ -693,10 +785,30 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
693
785
|
defaultOpen={
|
|
694
786
|
needle !== "" ||
|
|
695
787
|
group.selected.length > 0 ||
|
|
696
|
-
|
|
788
|
+
openByOrder.has(group.slug)
|
|
697
789
|
}
|
|
790
|
+
{...(props.dictionaryMode !== undefined
|
|
791
|
+
? { dictionaryMode: props.dictionaryMode }
|
|
792
|
+
: {})}
|
|
698
793
|
/>
|
|
699
794
|
))}
|
|
795
|
+
{tailFolded && (
|
|
796
|
+
<Button
|
|
797
|
+
style={{ alignSelf: "flex-start" }}
|
|
798
|
+
data-testid="facets-all-filters"
|
|
799
|
+
data-analytics="none"
|
|
800
|
+
data-analytics-reason="opening the filter tail is a read, not a flow step"
|
|
801
|
+
onClick={() => {
|
|
802
|
+
setTailOpen((was) => !was);
|
|
803
|
+
}}
|
|
804
|
+
>
|
|
805
|
+
{tailOpen
|
|
806
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
807
|
+
: t(SEARCH_I18N_KEYS.facetsAllFilters, {
|
|
808
|
+
count: listed.length - (groupLimit ?? 0),
|
|
809
|
+
})}
|
|
810
|
+
</Button>
|
|
811
|
+
)}
|
|
700
812
|
<Typography.Text type="secondary">
|
|
701
813
|
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
702
814
|
</Typography.Text>
|