@stapel/search-react 0.16.0 → 0.18.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 +87 -0
- package/dist/api/generated/schema.d.ts +18 -10
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +10 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +122 -27
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +25 -38
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +157 -6
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +25 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +39 -3
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +10 -1
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +9 -1
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +20 -7
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +5 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +19 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +25 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +7 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/state/facets.d.ts +14 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +19 -0
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +8 -2
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +18 -10
- package/src/default/FacetGroupControl.tsx +217 -60
- package/src/default/FacetPanelPane.tsx +226 -25
- package/src/default/FilterChips.tsx +69 -2
- package/src/default/SearchPage.tsx +15 -1
- package/src/default/SearchResultsPane.tsx +20 -7
- package/src/default/index.ts +2 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +25 -0
- package/src/i18n/ru.ts +7 -0
- package/src/state/facets.ts +19 -0
|
@@ -31,6 +31,31 @@
|
|
|
31
31
|
* A group with NO schema (the host passed no `categoryFeatures`, or the slug
|
|
32
32
|
* is not in it) is a flat checkbox list — the honest default, and the shape
|
|
33
33
|
* every group had before.
|
|
34
|
+
*
|
|
35
|
+
* ── The group can be a disclosure, and closed it is NOT in the DOM ─────────
|
|
36
|
+
*
|
|
37
|
+
* Measured on a live classified deployment's cars leaf at 1440×900: the 280px
|
|
38
|
+
* rail carried 5717px of content — 40 groups, 118 checkboxes, 66 fields — as
|
|
39
|
+
* one flat column. No amount of per-group folding fixes forty headings' worth
|
|
40
|
+
* of open controls, so the group itself can close: `collapsible` turns the
|
|
41
|
+
* label into a real `<button aria-expanded>` with a chevron and, when values
|
|
42
|
+
* are chosen inside, the count of them — the one fact a closed group owes its
|
|
43
|
+
* header. Closed means the options are not rendered at all: a hundred
|
|
44
|
+
* `display:none` checkboxes are still a hundred stops for a screen reader.
|
|
45
|
+
* Both props default to today's behaviour (always open, no disclosure), so no
|
|
46
|
+
* existing host changes; WHICH groups open is the panel's decision, not this
|
|
47
|
+
* component's — see `FacetPanelPane`.
|
|
48
|
+
*
|
|
49
|
+
* ── Uncounted options are the fold's tail, not the group's face ────────────
|
|
50
|
+
*
|
|
51
|
+
* The same walk found "not counted" printed 100+ times down the default view.
|
|
52
|
+
* The sentence is honest and it stays — but an option with no evidence behind
|
|
53
|
+
* it must not stand in front of one that has some, so options with
|
|
54
|
+
* `count: null` and nothing chosen sort AFTER every counted one and live
|
|
55
|
+
* behind the existing "Show all (N)" fold. A group whose options are ALL
|
|
56
|
+
* uncounted (a schema-only group — the server never counted the slug) keeps
|
|
57
|
+
* them visible as before: folding everything would leave a heading over
|
|
58
|
+
* nothing. Chosen options are always visible, wherever their count went.
|
|
34
59
|
*/
|
|
35
60
|
import { useState } from "react";
|
|
36
61
|
import type { CSSProperties, ReactElement } from "react";
|
|
@@ -114,28 +139,50 @@ export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
|
114
139
|
return singleChoice(feature) ? "segmented" : "checkbox";
|
|
115
140
|
}
|
|
116
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Is this node in the group's uncounted tail — an option with no evidence
|
|
144
|
+
* behind it AND no choice on it? Only meaningful in a group that has counted
|
|
145
|
+
* evidence at all: see {@link facetOptionNodes}.
|
|
146
|
+
*/
|
|
147
|
+
function uncountedUnchosen(node: FacetOptionNode): boolean {
|
|
148
|
+
return node.option.count === null && !node.option.selected;
|
|
149
|
+
}
|
|
150
|
+
|
|
117
151
|
/** The group's options in render order, carrying the depth the schema gives. */
|
|
118
152
|
export function facetOptionNodes(group: FacetGroup): readonly FacetOptionNode[] {
|
|
153
|
+
let nodes: readonly FacetOptionNode[];
|
|
119
154
|
if (facetGroupShape(group) !== "nested" || group.feature === undefined) {
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
nodes = group.options.map((option) => ({ option, depth: 0 }));
|
|
156
|
+
} else {
|
|
157
|
+
const depths = optionDepths(featureConfig(group.feature)["options"]);
|
|
158
|
+
// Depth alone would leave children beside strangers: the options are
|
|
159
|
+
// re-ordered so each child follows its own parent, and a value the schema
|
|
160
|
+
// does not know keeps depth 0 rather than being hidden under someone.
|
|
161
|
+
const byValue = new Map(group.options.map((option) => [option.value, option]));
|
|
162
|
+
const out: FacetOptionNode[] = [];
|
|
163
|
+
const emitted = new Set<string>();
|
|
164
|
+
for (const [value, depth] of depths) {
|
|
165
|
+
const option = byValue.get(value);
|
|
166
|
+
if (option === undefined) continue;
|
|
167
|
+
out.push({ option, depth });
|
|
168
|
+
emitted.add(value);
|
|
169
|
+
}
|
|
170
|
+
for (const option of group.options) {
|
|
171
|
+
if (!emitted.has(option.value)) out.push({ option, depth: 0 });
|
|
172
|
+
}
|
|
173
|
+
nodes = out;
|
|
137
174
|
}
|
|
138
|
-
|
|
175
|
+
|
|
176
|
+
// An option nobody counted must not stand in front of one with evidence:
|
|
177
|
+
// the uncounted, unchosen tail sorts after everything else (stable — both
|
|
178
|
+
// halves keep their own order) and folds behind "Show all" in the
|
|
179
|
+
// component below. Only in a group that HAS counted evidence: a schema-only
|
|
180
|
+
// group is all `null`, and demoting all of it would reorder nothing while
|
|
181
|
+
// telling the fold to hide the entire group behind its own heading.
|
|
182
|
+
if (!group.options.some((option) => option.count !== null)) return nodes;
|
|
183
|
+
const tail = nodes.filter(uncountedUnchosen);
|
|
184
|
+
if (tail.length === 0) return nodes;
|
|
185
|
+
return [...nodes.filter((node) => !uncountedUnchosen(node)), ...tail];
|
|
139
186
|
}
|
|
140
187
|
|
|
141
188
|
/** A count, or the honest "we did not count this". Never a zero standing in
|
|
@@ -229,6 +276,57 @@ function OptionPill(props: {
|
|
|
229
276
|
);
|
|
230
277
|
}
|
|
231
278
|
|
|
279
|
+
/**
|
|
280
|
+
* The disclosure header, as a NATIVE button with the chrome stripped.
|
|
281
|
+
*
|
|
282
|
+
* Native rather than antd's `Button`, because this is a heading that happens
|
|
283
|
+
* to toggle, not an action: it must inherit the heading's type and colour and
|
|
284
|
+
* sit flush with the group's left edge, and undoing a themed button's
|
|
285
|
+
* padding, border, background and hover for every surface it lands on is more
|
|
286
|
+
* style than the button brings. `aria-expanded` on a real `<button>` is the
|
|
287
|
+
* whole disclosure pattern; the chevron only draws what it already says.
|
|
288
|
+
*/
|
|
289
|
+
const DISCLOSURE_HEADER: CSSProperties = {
|
|
290
|
+
display: "flex",
|
|
291
|
+
alignItems: "center",
|
|
292
|
+
gap: spacing[1],
|
|
293
|
+
width: "100%",
|
|
294
|
+
padding: 0,
|
|
295
|
+
border: "none",
|
|
296
|
+
background: "none",
|
|
297
|
+
color: "inherit",
|
|
298
|
+
font: "inherit",
|
|
299
|
+
textAlign: "start",
|
|
300
|
+
cursor: "pointer",
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** The disclosure's state, drawn. Points down over a closed group ("there is
|
|
304
|
+
* more below") and flips once it is open. */
|
|
305
|
+
function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
|
|
306
|
+
return (
|
|
307
|
+
<svg
|
|
308
|
+
width="16"
|
|
309
|
+
height="16"
|
|
310
|
+
viewBox="0 0 24 24"
|
|
311
|
+
fill="none"
|
|
312
|
+
stroke="currentColor"
|
|
313
|
+
strokeWidth="2"
|
|
314
|
+
strokeLinecap="round"
|
|
315
|
+
strokeLinejoin="round"
|
|
316
|
+
role="img"
|
|
317
|
+
aria-hidden="true"
|
|
318
|
+
focusable="false"
|
|
319
|
+
style={{
|
|
320
|
+
marginInlineStart: "auto",
|
|
321
|
+
flex: "0 0 auto",
|
|
322
|
+
...(props.open ? { transform: "rotate(180deg)" } : {}),
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<path d="m6 9 6 6 6-6" />
|
|
326
|
+
</svg>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
232
330
|
export interface FacetGroupControlProps {
|
|
233
331
|
readonly group: FacetGroup;
|
|
234
332
|
readonly onToggle: (slug: string, value: string) => void;
|
|
@@ -238,23 +336,48 @@ export interface FacetGroupControlProps {
|
|
|
238
336
|
/** How many options before "Show all". Default {@link FACET_VISIBLE_OPTIONS};
|
|
239
337
|
* `null` shows every option (a sheet devoted to one group has the room). */
|
|
240
338
|
readonly visibleOptions?: number | null;
|
|
339
|
+
/**
|
|
340
|
+
* Make the group a disclosure: the heading becomes a real button and the
|
|
341
|
+
* options can leave the DOM entirely. Default `false` — today's always-open
|
|
342
|
+
* group, so no existing host changes. Meaningless with `heading: false`:
|
|
343
|
+
* a disclosure with no header is a group nothing can reopen.
|
|
344
|
+
*/
|
|
345
|
+
readonly collapsible?: boolean;
|
|
346
|
+
/** Whether a `collapsible` group STARTS open. Default `true`. The initial
|
|
347
|
+
* value only — the person owns the state after the first click. */
|
|
348
|
+
readonly defaultOpen?: boolean;
|
|
241
349
|
}
|
|
242
350
|
|
|
243
351
|
export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
244
352
|
const t = useT();
|
|
245
353
|
const { group } = props;
|
|
246
354
|
const [expanded, setExpanded] = useState(false);
|
|
355
|
+
const [openState, setOpenState] = useState(props.defaultOpen !== false);
|
|
247
356
|
const shape = facetGroupShape(group);
|
|
248
357
|
const nodes = facetOptionNodes(group);
|
|
249
358
|
|
|
359
|
+
const disclosure = props.collapsible === true && props.heading !== false;
|
|
360
|
+
const open = !disclosure || openState;
|
|
361
|
+
|
|
250
362
|
const limit =
|
|
251
363
|
props.visibleOptions === null
|
|
252
364
|
? null
|
|
253
365
|
: (props.visibleOptions ?? FACET_VISIBLE_OPTIONS);
|
|
366
|
+
// The demoted tail `facetOptionNodes` sorted to the end: uncounted,
|
|
367
|
+
// unchosen options in a group that has counted evidence. They are ALWAYS
|
|
368
|
+
// behind the fold — the "one row over the limit" exemption below is about
|
|
369
|
+
// not hiding one counted row, and these rows say "not counted".
|
|
370
|
+
const demoted = group.options.some((option) => option.count !== null)
|
|
371
|
+
? nodes.filter(uncountedUnchosen).length
|
|
372
|
+
: 0;
|
|
254
373
|
// A group one row over the limit is not truncated: hiding a single option
|
|
255
374
|
// behind "Show all (9)" costs a tap to reveal exactly one thing.
|
|
256
|
-
const
|
|
257
|
-
|
|
375
|
+
const folded =
|
|
376
|
+
limit !== null && (nodes.length > limit + 1 || demoted > 0);
|
|
377
|
+
const shown =
|
|
378
|
+
folded && !expanded
|
|
379
|
+
? nodes.slice(0, Math.min(limit ?? nodes.length, nodes.length - demoted))
|
|
380
|
+
: nodes;
|
|
258
381
|
|
|
259
382
|
return (
|
|
260
383
|
<Flex
|
|
@@ -264,48 +387,82 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
264
387
|
data-counted={group.counted ? "true" : "false"}
|
|
265
388
|
data-shape={shape}
|
|
266
389
|
>
|
|
267
|
-
{props.heading !== false &&
|
|
268
|
-
|
|
269
|
-
|
|
390
|
+
{props.heading !== false &&
|
|
391
|
+
(disclosure ? (
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
style={DISCLOSURE_HEADER}
|
|
395
|
+
aria-expanded={open}
|
|
396
|
+
data-testid={`facet-toggle-${group.slug}`}
|
|
397
|
+
data-analytics="none"
|
|
398
|
+
data-analytics-reason="opening a filter group is a read, not a flow step"
|
|
399
|
+
onClick={() => {
|
|
400
|
+
setOpenState((was) => !was);
|
|
401
|
+
}}
|
|
402
|
+
>
|
|
403
|
+
<Typography.Text strong>{group.label}</Typography.Text>
|
|
404
|
+
{/* The one fact a closed group owes its header: how many of its
|
|
405
|
+
values are CHOSEN. Not the option count — that is what the
|
|
406
|
+
fold's own "Show all (N)" answers once the group is open. */}
|
|
407
|
+
{group.selected.length > 0 && (
|
|
408
|
+
<Typography.Text
|
|
409
|
+
type="secondary"
|
|
410
|
+
data-testid={`facet-toggle-count-${group.slug}`}
|
|
411
|
+
>
|
|
412
|
+
{group.selected.length}
|
|
413
|
+
</Typography.Text>
|
|
414
|
+
)}
|
|
415
|
+
<ChevronGlyph open={open} />
|
|
416
|
+
</button>
|
|
417
|
+
) : (
|
|
418
|
+
<Typography.Text strong>{group.label}</Typography.Text>
|
|
419
|
+
))}
|
|
270
420
|
|
|
271
|
-
{
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
421
|
+
{/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
|
|
422
|
+
checkboxes are still a hundred stops for a screen reader, and the
|
|
423
|
+
measured rail held 118 of them. */}
|
|
424
|
+
{open && (
|
|
425
|
+
<>
|
|
426
|
+
{shape === "segmented" ? (
|
|
427
|
+
<Flex wrap gap={spacing[2]}>
|
|
428
|
+
{shown.map((node) => (
|
|
429
|
+
<OptionPill
|
|
430
|
+
key={node.option.value}
|
|
431
|
+
group={group}
|
|
432
|
+
option={node.option}
|
|
433
|
+
onToggle={props.onToggle}
|
|
434
|
+
/>
|
|
435
|
+
))}
|
|
436
|
+
</Flex>
|
|
437
|
+
) : (
|
|
438
|
+
shown.map((node) => (
|
|
439
|
+
<CheckboxRow
|
|
440
|
+
key={node.option.value}
|
|
441
|
+
group={group}
|
|
442
|
+
node={node}
|
|
443
|
+
onToggle={props.onToggle}
|
|
444
|
+
/>
|
|
445
|
+
))
|
|
446
|
+
)}
|
|
292
447
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
448
|
+
{folded && (
|
|
449
|
+
<Button
|
|
450
|
+
type="link"
|
|
451
|
+
size="small"
|
|
452
|
+
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
453
|
+
data-testid={`facet-more-${group.slug}`}
|
|
454
|
+
data-analytics="none"
|
|
455
|
+
data-analytics-reason="expanding a filter group is a read, not a flow step"
|
|
456
|
+
onClick={() => {
|
|
457
|
+
setExpanded((was) => !was);
|
|
458
|
+
}}
|
|
459
|
+
>
|
|
460
|
+
{expanded
|
|
461
|
+
? t(SEARCH_I18N_KEYS.facetsShowLess)
|
|
462
|
+
: t(SEARCH_I18N_KEYS.facetsShowAll, { count: nodes.length })}
|
|
463
|
+
</Button>
|
|
464
|
+
)}
|
|
465
|
+
</>
|
|
309
466
|
)}
|
|
310
467
|
</Flex>
|
|
311
468
|
);
|
|
@@ -35,17 +35,44 @@
|
|
|
35
35
|
* than a silent absence — and, in every build, any constraint the URL already
|
|
36
36
|
* carries gets a control that REMOVES it. A shared link that narrows to a
|
|
37
37
|
* category or a point must never leave a person with no way to widen it again.
|
|
38
|
+
*
|
|
39
|
+
* ── The panel opens what the answer argues for, and closes the rest ────────
|
|
40
|
+
*
|
|
41
|
+
* Measured on a live classified deployment's cars leaf at 1440×900: this
|
|
42
|
+
* panel, in a 280px rail, was 5717px tall — 40 groups, 118 checkboxes, 66
|
|
43
|
+
* fields, one flat column whose tail no scroll a person actually performs
|
|
44
|
+
* ever reaches. The phone sheet drew the same column six screens deep.
|
|
45
|
+
*
|
|
46
|
+
* So the groups are disclosures now, and WHICH open is decided here, from
|
|
47
|
+
* evidence the panel already holds: a group with any chosen value is always
|
|
48
|
+
* open (a constraint must keep its control in sight), and otherwise the top
|
|
49
|
+
* {@link FACET_OPEN_GROUPS} counted groups by {@link facetCoverage} — the sum
|
|
50
|
+
* of an axis's counts is the answer's own statement of how many documents
|
|
51
|
+
* carry it, the same reasoning the chip row sorts its band by. Everything
|
|
52
|
+
* else starts as a header, one click from whole. A group the server never
|
|
53
|
+
* counted sums to zero and therefore never opens uninvited, which is what
|
|
54
|
+
* removes the wall of "not counted" rows from the default view without
|
|
55
|
+
* deleting one of them.
|
|
56
|
+
*
|
|
57
|
+
* From {@link FACET_SEARCH_THRESHOLD} groups up the panel also takes a search
|
|
58
|
+
* of ITSELF — a person who cannot scan forty headers should not have to open
|
|
59
|
+
* them one by one to find the axle count. It narrows by group or option
|
|
60
|
+
* label, opens what it matches, and touches presentation only: the URL is
|
|
61
|
+
* state and a panel query is not.
|
|
38
62
|
*/
|
|
63
|
+
import { useState } from "react";
|
|
39
64
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
40
65
|
import {
|
|
41
66
|
Alert,
|
|
42
67
|
Button,
|
|
43
68
|
Divider,
|
|
44
69
|
Flex,
|
|
70
|
+
Input,
|
|
45
71
|
InputNumber,
|
|
46
72
|
Typography,
|
|
73
|
+
theme,
|
|
47
74
|
} from "antd";
|
|
48
|
-
import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
|
|
75
|
+
import { SlotPlaceholder, isDevBuild, useT, useTPlural } from "@stapel/core";
|
|
49
76
|
import {
|
|
50
77
|
EmptyState,
|
|
51
78
|
ErrorAlert,
|
|
@@ -59,6 +86,9 @@ import type { SearchGeo } from "../api/types.js";
|
|
|
59
86
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
60
87
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
61
88
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
89
|
+
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
90
|
+
import { facetCoverage } from "../state/facets.js";
|
|
91
|
+
import type { FacetGroup } from "../state/facets.js";
|
|
62
92
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
63
93
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
64
94
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -123,6 +153,21 @@ const FACET_CLEAR: CSSProperties = {
|
|
|
123
153
|
textAlign: "start",
|
|
124
154
|
};
|
|
125
155
|
|
|
156
|
+
/**
|
|
157
|
+
* How many counted groups open by default — beyond the ones with chosen
|
|
158
|
+
* values, which always do. Five open groups at the default type step fill
|
|
159
|
+
* roughly one 900px window of the rail: the panel's first screen is the five
|
|
160
|
+
* axes this corpus is most narrowed by, and everything after is a header.
|
|
161
|
+
*/
|
|
162
|
+
export const FACET_OPEN_GROUPS = 5;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* From how many groups the panel offers a search of itself. Under six the
|
|
166
|
+
* headers fit a glance and a box would be chrome over nothing; the measured
|
|
167
|
+
* leaf had forty.
|
|
168
|
+
*/
|
|
169
|
+
export const FACET_SEARCH_THRESHOLD = 6;
|
|
170
|
+
|
|
126
171
|
/** What a host's category control is handed. */
|
|
127
172
|
export interface CategoryFilterSlotProps {
|
|
128
173
|
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
@@ -210,6 +255,87 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
210
255
|
* on a staging surface. Nothing else changes with it.
|
|
211
256
|
*/
|
|
212
257
|
readonly skippedNotice?: boolean;
|
|
258
|
+
/**
|
|
259
|
+
* Draw the sticky footer inside the panel: the live result count as the
|
|
260
|
+
* bar's strong text, and the clear-all control (which then moves out of the
|
|
261
|
+
* heading row — one control, not two) beside it. Default `false`.
|
|
262
|
+
*
|
|
263
|
+
* `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
|
|
264
|
+
* apply instantly, so the bar is FEEDBACK plus the way out, not an apply
|
|
265
|
+
* button — which is exactly why the phone sheet must not get it: the sheet
|
|
266
|
+
* already closes through its own "Show N results" footer, and a second
|
|
267
|
+
* count-bearing bar above that one would be the same sentence twice.
|
|
268
|
+
*/
|
|
269
|
+
readonly footerBar?: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The rail's sticky floor: what the filters DID (the live count), and the way
|
|
274
|
+
* out of them (clear all). It sticks to the bottom of the rail's own scroll,
|
|
275
|
+
* so however deep the panel goes the answer stays on screen.
|
|
276
|
+
*
|
|
277
|
+
* The count reuses `useAppliedCount` — the answer already in cache, never a
|
|
278
|
+
* second request — and its honesty rules: an exact count gets the counted
|
|
279
|
+
* noun ("N listings match"), a floor keeps the existing "N+" family, and an
|
|
280
|
+
* engine that cannot say gets NO number, because a fabricated one on the
|
|
281
|
+
* surface that reports what filtering did is worse than silence. With
|
|
282
|
+
* nothing to say and nothing to clear, no bar.
|
|
283
|
+
*/
|
|
284
|
+
function RailFooterBar(props: {
|
|
285
|
+
readonly activeFilters: number;
|
|
286
|
+
readonly clearAll: () => void;
|
|
287
|
+
}): ReactElement | null {
|
|
288
|
+
const t = useT();
|
|
289
|
+
const tPlural = useTPlural();
|
|
290
|
+
// The token bag of the nearest theme, so the bar's ground and hairline are
|
|
291
|
+
// the panel's own in both modes — a hard-coded white floor would glow in
|
|
292
|
+
// the dark theme.
|
|
293
|
+
const { token } = theme.useToken();
|
|
294
|
+
const applied = useAppliedCount();
|
|
295
|
+
const countText =
|
|
296
|
+
applied.count === null || applied.kind === "unknown"
|
|
297
|
+
? null
|
|
298
|
+
: tPlural(
|
|
299
|
+
applied.kind === "at_least"
|
|
300
|
+
? SEARCH_I18N_KEYS.resultsCountAtLeast
|
|
301
|
+
: SEARCH_I18N_KEYS.facetsMatchCount,
|
|
302
|
+
{ count: applied.count }
|
|
303
|
+
);
|
|
304
|
+
if (countText === null && props.activeFilters === 0) return null;
|
|
305
|
+
return (
|
|
306
|
+
<div
|
|
307
|
+
data-testid="facets-footer-bar"
|
|
308
|
+
style={{
|
|
309
|
+
position: "sticky",
|
|
310
|
+
bottom: 0,
|
|
311
|
+
// Opaque, or the options scrolling under the bar read THROUGH it.
|
|
312
|
+
background: token.colorBgContainer,
|
|
313
|
+
borderBlockStart: `1px solid ${token.colorSplit}`,
|
|
314
|
+
paddingBlockStart: spacing[2],
|
|
315
|
+
display: "flex",
|
|
316
|
+
flexDirection: "column",
|
|
317
|
+
alignItems: "flex-start",
|
|
318
|
+
gap: spacing[2],
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
{countText !== null && (
|
|
322
|
+
<Typography.Text strong data-testid="facets-footer-count">
|
|
323
|
+
{countText}
|
|
324
|
+
</Typography.Text>
|
|
325
|
+
)}
|
|
326
|
+
{props.activeFilters > 0 && (
|
|
327
|
+
<Button
|
|
328
|
+
style={FACET_CLEAR}
|
|
329
|
+
onClick={props.clearAll}
|
|
330
|
+
data-analytics="none"
|
|
331
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
332
|
+
data-testid="facets-clear-all"
|
|
333
|
+
>
|
|
334
|
+
{t(SEARCH_I18N_KEYS.facetsClearAll, { count: props.activeFilters })}
|
|
335
|
+
</Button>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
213
339
|
}
|
|
214
340
|
|
|
215
341
|
/** The category constraint: the host's control, or the door out of it. */
|
|
@@ -401,6 +527,10 @@ function skippedNames(
|
|
|
401
527
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
402
528
|
const t = useT();
|
|
403
529
|
const { state } = useSearchState();
|
|
530
|
+
// The panel-search's text. COMPONENT state on purpose: it narrows how the
|
|
531
|
+
// panel is drawn, never what the search is, so it must not survive into a
|
|
532
|
+
// shared link the way everything in `useSearchState` does.
|
|
533
|
+
const [filterQuery, setFilterQuery] = useState("");
|
|
404
534
|
|
|
405
535
|
return (
|
|
406
536
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -455,7 +585,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
455
585
|
{props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
|
|
456
586
|
</Typography.Title>
|
|
457
587
|
)}
|
|
458
|
-
{
|
|
588
|
+
{/* With the footer bar on, clear-all lives THERE — beside the
|
|
589
|
+
count it acts on — and drawing it here too would be two
|
|
590
|
+
identical exits one panel apart. */}
|
|
591
|
+
{bag.activeFilters > 0 && props.footerBar !== true && (
|
|
459
592
|
<Button
|
|
460
593
|
style={FACET_CLEAR}
|
|
461
594
|
onClick={bag.clearAll}
|
|
@@ -538,35 +671,103 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
538
671
|
/>
|
|
539
672
|
)}
|
|
540
673
|
>
|
|
541
|
-
{(groups) =>
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
674
|
+
{(groups) => {
|
|
675
|
+
// A group with no options is a heading with nothing under
|
|
676
|
+
// it. What is left in that state after `buildFacetGroups`
|
|
677
|
+
// learned to read the schema is the genuinely unanswerable
|
|
678
|
+
// case: a `ref_select` whose config is a bare pointer into
|
|
679
|
+
// a vocabulary this pair cannot read. A heading with no
|
|
680
|
+
// control under it names nothing, so it is not drawn.
|
|
681
|
+
const drawable = groups.filter(
|
|
682
|
+
(group) => group.options.length > 0
|
|
683
|
+
);
|
|
684
|
+
// Which groups OPEN — see the module note. Chosen groups are
|
|
685
|
+
// open unconditionally below; here the answer's evidence
|
|
686
|
+
// picks the rest: the top counted groups by coverage, and a
|
|
687
|
+
// group the server never counted sums to zero, so the wall
|
|
688
|
+
// of "not counted" rows starts as headers.
|
|
689
|
+
const openByEvidence = new Set(
|
|
690
|
+
drawable
|
|
691
|
+
.filter((group) => group.counted)
|
|
692
|
+
.map((group) => [group, facetCoverage(group)] as const)
|
|
693
|
+
.sort((a, b) => b[1] - a[1])
|
|
694
|
+
.slice(0, FACET_OPEN_GROUPS)
|
|
695
|
+
.map(([group]) => group.slug)
|
|
696
|
+
);
|
|
697
|
+
const searchable = drawable.length >= FACET_SEARCH_THRESHOLD;
|
|
698
|
+
const needle = searchable
|
|
699
|
+
? filterQuery.trim().toLowerCase()
|
|
700
|
+
: "";
|
|
701
|
+
const matches = (group: FacetGroup): boolean =>
|
|
702
|
+
group.label.toLowerCase().includes(needle) ||
|
|
703
|
+
group.options.some((option) =>
|
|
704
|
+
option.label.toLowerCase().includes(needle)
|
|
705
|
+
);
|
|
706
|
+
const listed =
|
|
707
|
+
needle === "" ? drawable : drawable.filter(matches);
|
|
708
|
+
return (
|
|
709
|
+
<Flex vertical gap={spacing[4]}>
|
|
710
|
+
{searchable && (
|
|
711
|
+
<Input
|
|
712
|
+
allowClear
|
|
713
|
+
value={filterQuery}
|
|
714
|
+
placeholder={t(SEARCH_I18N_KEYS.facetsSearch)}
|
|
715
|
+
aria-label={t(SEARCH_I18N_KEYS.facetsSearch)}
|
|
716
|
+
data-testid="facets-search"
|
|
717
|
+
onChange={(event) => {
|
|
718
|
+
setFilterQuery(event.target.value);
|
|
719
|
+
}}
|
|
720
|
+
/>
|
|
721
|
+
)}
|
|
722
|
+
{/* The query missed. The groups are still there — one
|
|
723
|
+
cleared box away — so this is the panel-search's empty
|
|
724
|
+
state, not the panel's. */}
|
|
725
|
+
{needle !== "" && listed.length === 0 && (
|
|
726
|
+
<EmptyState
|
|
727
|
+
compact
|
|
728
|
+
title={t(SEARCH_I18N_KEYS.facetsSearchEmpty)}
|
|
729
|
+
testId="facets-search-empty"
|
|
730
|
+
/>
|
|
731
|
+
)}
|
|
732
|
+
{/* Each group draws itself the way its own schema says:
|
|
733
|
+
pills for a single-choice facet, indented children for a
|
|
734
|
+
hierarchical one, a fold for a long one. The panel does
|
|
735
|
+
not decide — `facetGroupShape` reads the same config keys
|
|
736
|
+
the attributes editor reads, so a facet cannot look one
|
|
737
|
+
way here and another way in the composer. */}
|
|
738
|
+
{/* The key changes with the query's presence ON PURPOSE:
|
|
739
|
+
`defaultOpen` is an initial value, and a group the
|
|
740
|
+
panel-search matched has to render OPEN — a hit behind
|
|
741
|
+
a closed header is not an answer. Remounting is the
|
|
742
|
+
honest way to re-ask the question; the person's own
|
|
743
|
+
opens and closes come back when the box clears. */}
|
|
744
|
+
{listed.map((group) => (
|
|
558
745
|
<FacetGroupControl
|
|
559
|
-
key={group.slug}
|
|
746
|
+
key={needle === "" ? group.slug : `${group.slug}:match`}
|
|
560
747
|
group={group}
|
|
561
748
|
onToggle={bag.toggle}
|
|
749
|
+
collapsible
|
|
750
|
+
defaultOpen={
|
|
751
|
+
needle !== "" ||
|
|
752
|
+
group.selected.length > 0 ||
|
|
753
|
+
openByEvidence.has(group.slug)
|
|
754
|
+
}
|
|
562
755
|
/>
|
|
563
756
|
))}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
757
|
+
<Typography.Text type="secondary">
|
|
758
|
+
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
759
|
+
</Typography.Text>
|
|
760
|
+
</Flex>
|
|
761
|
+
);
|
|
762
|
+
}}
|
|
569
763
|
</LoadList>
|
|
764
|
+
|
|
765
|
+
{props.footerBar === true && (
|
|
766
|
+
<RailFooterBar
|
|
767
|
+
activeFilters={bag.activeFilters}
|
|
768
|
+
clearAll={bag.clearAll}
|
|
769
|
+
/>
|
|
770
|
+
)}
|
|
570
771
|
</Flex>
|
|
571
772
|
);
|
|
572
773
|
}}
|