@stapel/search-react 0.28.0 → 0.29.1
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 +63 -0
- package/README.md +37 -3
- package/dist/default/FacetGroupControl.d.ts +33 -6
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +167 -4
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +5 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +5 -3
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts +16 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +22 -1
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +10 -6
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +1 -1
- package/dist/default/SearchPage.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/headless/FacetPanel.d.ts +9 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +1 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +15 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +18 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +4 -1
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +278 -22
- package/src/default/FacetPanelPane.tsx +55 -18
- package/src/default/RangeFilterRow.tsx +44 -1
- package/src/default/SearchPage.tsx +12 -8
- package/src/default/index.ts +1 -0
- package/src/headless/FacetPanel.tsx +10 -0
- package/src/i18n/es.ts +3 -0
- package/src/i18n/keys.ts +18 -0
- package/src/i18n/ru.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"limit": "13.75 KB"
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
|
-
"name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab",
|
|
60
|
+
"name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab, then 28.5 → 29.25 KB for the phone's dictionary SHEET — a trigger row over the shared `SkinPickerSheet` with a recommended band, an alphabetical rest paged fifty at a time and a draft committed in one write, which is the control the composer's vocabulary picker already was while the buyer got a wall of checkboxes",
|
|
61
61
|
"path": "dist/default/index.js",
|
|
62
|
-
"limit": "
|
|
62
|
+
"limit": "29.25 KB"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"name": "router — the react-router binding is opt-in; the main entry must never pull a router",
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
"limit": "1 KB"
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
|
-
"name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry",
|
|
70
|
+
"name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry. Raised 4.75 → 5 KB for the dictionary sheet's three headings",
|
|
71
71
|
"path": "dist/i18n/ru.js",
|
|
72
|
-
"limit": "
|
|
72
|
+
"limit": "5 KB"
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"name": "i18n/es — the Spanish bundle is opt-in, not carried by the main entry. Raised 3.5 → 3.75 KB for the three strings of the other-categories line",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"@stapel/attributes-react": ">=0.4.0",
|
|
82
82
|
"@stapel/core": ">=0.18.1 <1.0.0",
|
|
83
83
|
"@stapel/image": ">=0.3.0",
|
|
84
|
-
"@stapel/tokens-antd": ">=0.
|
|
84
|
+
"@stapel/tokens-antd": ">=0.12.0",
|
|
85
85
|
"@tanstack/react-query": "^5.0.0",
|
|
86
86
|
"antd": ">=5.20.0 <7",
|
|
87
87
|
"react": ">=19",
|
|
@@ -116,10 +116,10 @@
|
|
|
116
116
|
"typescript": "^5.8.3",
|
|
117
117
|
"vitest": "^3.2.4",
|
|
118
118
|
"@stapel/attributes-react": "^0.16.0",
|
|
119
|
-
"@stapel/image": "^0.4.2",
|
|
120
119
|
"@stapel/showcase": "^0.3.0",
|
|
121
|
-
"@stapel/tokens": "^0.7.0",
|
|
122
120
|
"@stapel/core": "^0.25.1",
|
|
121
|
+
"@stapel/image": "^0.4.2",
|
|
122
|
+
"@stapel/tokens": "^0.7.0",
|
|
123
123
|
"@stapel/tokens-antd": "^0.17.0"
|
|
124
124
|
},
|
|
125
125
|
"engines": {
|
|
@@ -62,10 +62,12 @@
|
|
|
62
62
|
* them visible as before: folding everything would leave a heading over
|
|
63
63
|
* nothing. Chosen options are always visible, wherever their count went.
|
|
64
64
|
*/
|
|
65
|
-
import { useState } from "react";
|
|
65
|
+
import { useMemo, 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 { SkinPickerSheet } from "@stapel/tokens-antd/skin";
|
|
70
|
+
import type { PickerGroup, PickerOption } from "@stapel/tokens-antd/skin";
|
|
69
71
|
import { controls, cssVar, radii, spacing } from "@stapel/tokens";
|
|
70
72
|
import { featureConfig, featureType } from "@stapel/attributes-react";
|
|
71
73
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
@@ -632,6 +634,246 @@ function DictionaryField(props: {
|
|
|
632
634
|
);
|
|
633
635
|
}
|
|
634
636
|
|
|
637
|
+
/**
|
|
638
|
+
* How many values the sheet's «All values» band adds per page.
|
|
639
|
+
*
|
|
640
|
+
* Fifty is the vocabulary endpoint's own page size, and it is the number past
|
|
641
|
+
* which a phone list stops being scrolled and starts being searched. Reaching
|
|
642
|
+
* the end of the list asks for the next fifty, so a 418-make level is a
|
|
643
|
+
* scroll rather than 418 mounted rows.
|
|
644
|
+
*/
|
|
645
|
+
export const FACET_SHEET_PAGE = 50;
|
|
646
|
+
|
|
647
|
+
/** One facet option as the shared picker reads it. The count rides along as
|
|
648
|
+
* the row's second line: a filter without its remaining count is the
|
|
649
|
+
* drill-down facet turned naive, and that rule does not stop at a sheet. */
|
|
650
|
+
function pickerRow(option: FacetOption, notCounted: string): PickerOption {
|
|
651
|
+
return {
|
|
652
|
+
value: option.value,
|
|
653
|
+
label: option.label,
|
|
654
|
+
description: option.count === null ? notCounted : String(option.count),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* A dictionary group on a PHONE: a trigger row, and a nested sheet.
|
|
660
|
+
*
|
|
661
|
+
* ── What the buyer was looking at ─────────────────────────────────────────
|
|
662
|
+
*
|
|
663
|
+
* The composer's own vocabulary picker (`@stapel/attributes-react`'s
|
|
664
|
+
* `ref_select` editor) is a trigger that opens a sheet with a search box, a
|
|
665
|
+
* recommended band and the rest — zero checkboxes. The buyer's filter sheet
|
|
666
|
+
* drew the SAME axis as a wall of eight checkboxes with a "Find a value" box
|
|
667
|
+
* and a "Show all (38)" under it, and no way to say "any". Two halves of one
|
|
668
|
+
* product teaching two different gestures for one dictionary is the defect;
|
|
669
|
+
* this mode is the half that moved.
|
|
670
|
+
*
|
|
671
|
+
* ── Why the shared `SkinPickerSheet` and not a local one ──────────────────
|
|
672
|
+
*
|
|
673
|
+
* It is the same component the composer's picker draws: the search box pinned
|
|
674
|
+
* above the list, the checkmarks, the commit button above the home indicator,
|
|
675
|
+
* the swipe/Esc/back dismissal, the skeleton and the empty arm. A pair that
|
|
676
|
+
* re-derives that gets a near-miss of it, which is exactly how the two halves
|
|
677
|
+
* drifted the first time.
|
|
678
|
+
*
|
|
679
|
+
* ── The two bands ─────────────────────────────────────────────────────────
|
|
680
|
+
*
|
|
681
|
+
* «Recommended» is the busiest values BY COUNT — the answer's own evidence,
|
|
682
|
+
* capped at {@link FACET_VISIBLE_OPTIONS} — with the chosen values in front
|
|
683
|
+
* of it, because a filter a person cannot see is a filter they cannot remove.
|
|
684
|
+
* «All values» is everything else, alphabetically, a page at a time. Once
|
|
685
|
+
* something is typed the bands collapse into one: a "Recommended" heading
|
|
686
|
+
* over rows that do not answer the box is the stale-list defect wearing a
|
|
687
|
+
* hat.
|
|
688
|
+
*
|
|
689
|
+
* The box filters LOCALLY and across alphabets (`translitPrefixMatch`, the
|
|
690
|
+
* same matcher the desktop field uses), so a Cyrillic spelling of a Latin
|
|
691
|
+
* make finds it. Nothing here touches the URL: what a person typed to FIND a
|
|
692
|
+
* filter is not part of the search they would share. The COMMIT does — the
|
|
693
|
+
* whole draft is written to the slug's URL key at once.
|
|
694
|
+
*/
|
|
695
|
+
function DictionarySheet(props: {
|
|
696
|
+
readonly group: FacetGroup;
|
|
697
|
+
readonly onSetValues: (slug: string, values: readonly string[]) => void;
|
|
698
|
+
}): ReactElement {
|
|
699
|
+
const t = useT();
|
|
700
|
+
const { group } = props;
|
|
701
|
+
const [open, setOpen] = useState(false);
|
|
702
|
+
const [needle, setNeedle] = useState("");
|
|
703
|
+
const [page, setPage] = useState(FACET_SHEET_PAGE);
|
|
704
|
+
|
|
705
|
+
const chosen = group.options.filter((option) => option.selected);
|
|
706
|
+
const query = needle.trim();
|
|
707
|
+
|
|
708
|
+
const { groups, total } = useMemo(() => {
|
|
709
|
+
const notCounted = t(SEARCH_I18N_KEYS.facetsNotCounted);
|
|
710
|
+
const hit = (option: FacetOption): boolean =>
|
|
711
|
+
query === "" ||
|
|
712
|
+
translitPrefixMatch(query, option.label) ||
|
|
713
|
+
translitPrefixMatch(query, option.value);
|
|
714
|
+
const selected = group.options.filter((option) => option.selected);
|
|
715
|
+
const byCount = [...group.options].sort(
|
|
716
|
+
(a, b) => (b.count ?? 0) - (a.count ?? 0)
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
if (query !== "") {
|
|
720
|
+
// One band while the box holds something: the chosen values first (they
|
|
721
|
+
// are the ones with an off-switch to reach), then the evidence order.
|
|
722
|
+
const hits = [
|
|
723
|
+
...selected.filter(hit),
|
|
724
|
+
...byCount.filter((option) => !option.selected && hit(option)),
|
|
725
|
+
];
|
|
726
|
+
return {
|
|
727
|
+
total: hits.length,
|
|
728
|
+
groups: [
|
|
729
|
+
{
|
|
730
|
+
key: "all",
|
|
731
|
+
label: t(SEARCH_I18N_KEYS.facetsDictionaryAllValues),
|
|
732
|
+
options: hits.slice(0, page).map((o) => pickerRow(o, notCounted)),
|
|
733
|
+
},
|
|
734
|
+
] as readonly PickerGroup[],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// The band never drops a chosen value, however cold it is: its cap grows
|
|
739
|
+
// to hold them rather than pushing one of them into the alphabet.
|
|
740
|
+
const cap = Math.max(FACET_VISIBLE_OPTIONS, selected.length);
|
|
741
|
+
const band: FacetOption[] = [];
|
|
742
|
+
const banded = new Set<string>();
|
|
743
|
+
for (const option of [...selected, ...byCount]) {
|
|
744
|
+
if (banded.has(option.value) || band.length >= cap) continue;
|
|
745
|
+
band.push(option);
|
|
746
|
+
banded.add(option.value);
|
|
747
|
+
}
|
|
748
|
+
const rest = group.options
|
|
749
|
+
.filter((option) => !banded.has(option.value))
|
|
750
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
751
|
+
return {
|
|
752
|
+
total: band.length + Math.min(rest.length, page),
|
|
753
|
+
groups: [
|
|
754
|
+
{
|
|
755
|
+
key: "band",
|
|
756
|
+
label: t(SEARCH_I18N_KEYS.facetsDictionaryRecommended),
|
|
757
|
+
options: band.map((o) => pickerRow(o, notCounted)),
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
key: "all",
|
|
761
|
+
label: t(SEARCH_I18N_KEYS.facetsDictionaryAllValues),
|
|
762
|
+
options: rest.slice(0, page).map((o) => pickerRow(o, notCounted)),
|
|
763
|
+
},
|
|
764
|
+
] as readonly PickerGroup[],
|
|
765
|
+
};
|
|
766
|
+
}, [group, query, page, t]);
|
|
767
|
+
|
|
768
|
+
// How many rows exist behind the current page — what "there is more" means.
|
|
769
|
+
const available = group.options.length;
|
|
770
|
+
|
|
771
|
+
const text =
|
|
772
|
+
chosen.length > 0
|
|
773
|
+
? chosen.map((option) => option.label).join(", ")
|
|
774
|
+
: t(SEARCH_I18N_KEYS.facetsDictionaryAny);
|
|
775
|
+
|
|
776
|
+
return (
|
|
777
|
+
<>
|
|
778
|
+
<button
|
|
779
|
+
type="button"
|
|
780
|
+
aria-haspopup="dialog"
|
|
781
|
+
aria-expanded={open}
|
|
782
|
+
aria-label={group.label}
|
|
783
|
+
style={DICTIONARY_FIELD}
|
|
784
|
+
data-testid={`facet-dictionary-trigger-${group.slug}`}
|
|
785
|
+
data-chosen={chosen.length}
|
|
786
|
+
data-analytics="none"
|
|
787
|
+
data-analytics-reason="opening a filter group is a read, not a flow step"
|
|
788
|
+
onClick={() => {
|
|
789
|
+
setOpen(true);
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
<span style={DICTIONARY_FIELD_TEXT}>{text}</span>
|
|
793
|
+
{chosen.length > 0 && (
|
|
794
|
+
<Typography.Text
|
|
795
|
+
type="secondary"
|
|
796
|
+
data-testid={`facet-dictionary-trigger-count-${group.slug}`}
|
|
797
|
+
>
|
|
798
|
+
{chosen.length}
|
|
799
|
+
</Typography.Text>
|
|
800
|
+
)}
|
|
801
|
+
<ChevronGlyph open={open} />
|
|
802
|
+
</button>
|
|
803
|
+
<SkinPickerSheet
|
|
804
|
+
mode="multi"
|
|
805
|
+
open={open}
|
|
806
|
+
onClose={() => {
|
|
807
|
+
setOpen(false);
|
|
808
|
+
setNeedle("");
|
|
809
|
+
setPage(FACET_SHEET_PAGE);
|
|
810
|
+
}}
|
|
811
|
+
title={group.label}
|
|
812
|
+
testId={`facet-dictionary-sheet-${group.slug}`}
|
|
813
|
+
doneLabel={t(SEARCH_I18N_KEYS.facetsDictionaryDone)}
|
|
814
|
+
searchPlaceholder={t(SEARCH_I18N_KEYS.facetsDictionarySearch)}
|
|
815
|
+
emptyLabel={t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
|
|
816
|
+
// The caller owns the filtering — the sheet's own local filter matches
|
|
817
|
+
// on the label only, and this axis is searched across alphabets.
|
|
818
|
+
searchValue={needle}
|
|
819
|
+
onSearchChange={(next) => {
|
|
820
|
+
setNeedle(next);
|
|
821
|
+
setPage(FACET_SHEET_PAGE);
|
|
822
|
+
}}
|
|
823
|
+
groups={groups}
|
|
824
|
+
// Everything handed over is drawn: the paging above is this
|
|
825
|
+
// component's, so the sheet's own row cap must not fold it again.
|
|
826
|
+
maxRows={Math.max(1, total)}
|
|
827
|
+
onEndReached={() => {
|
|
828
|
+
setPage((current) =>
|
|
829
|
+
current >= available ? current : current + FACET_SHEET_PAGE
|
|
830
|
+
);
|
|
831
|
+
}}
|
|
832
|
+
values={group.selected}
|
|
833
|
+
onChange={(values) => {
|
|
834
|
+
props.onSetValues(group.slug, values);
|
|
835
|
+
}}
|
|
836
|
+
/>
|
|
837
|
+
</>
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The three faces of one dictionary, chosen by mode.
|
|
843
|
+
*
|
|
844
|
+
* `"sheet"` without a bulk setter falls back to the FIELD rather than to the
|
|
845
|
+
* inline wall: a trigger that opens a list is the shape both surfaces are
|
|
846
|
+
* moving to, and the wall is the thing the pass named.
|
|
847
|
+
*/
|
|
848
|
+
function DictionaryControl(props: {
|
|
849
|
+
readonly group: FacetGroup;
|
|
850
|
+
readonly mode: "field" | "inline" | "sheet" | undefined;
|
|
851
|
+
readonly onToggle: (slug: string, value: string) => void;
|
|
852
|
+
readonly onSetValues?: (slug: string, values: readonly string[]) => void;
|
|
853
|
+
readonly visible: number;
|
|
854
|
+
}): ReactElement {
|
|
855
|
+
const { onSetValues } = props;
|
|
856
|
+
if (props.mode === "sheet" && onSetValues !== undefined) {
|
|
857
|
+
return <DictionarySheet group={props.group} onSetValues={onSetValues} />;
|
|
858
|
+
}
|
|
859
|
+
if (props.mode === "field" || props.mode === "sheet") {
|
|
860
|
+
return (
|
|
861
|
+
<DictionaryField
|
|
862
|
+
group={props.group}
|
|
863
|
+
onToggle={props.onToggle}
|
|
864
|
+
visible={props.visible}
|
|
865
|
+
/>
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
return (
|
|
869
|
+
<DictionaryBody
|
|
870
|
+
group={props.group}
|
|
871
|
+
onToggle={props.onToggle}
|
|
872
|
+
visible={props.visible}
|
|
873
|
+
/>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
635
877
|
export interface FacetGroupControlProps {
|
|
636
878
|
readonly group: FacetGroup;
|
|
637
879
|
readonly onToggle: (slug: string, value: string) => void;
|
|
@@ -652,13 +894,31 @@ export interface FacetGroupControlProps {
|
|
|
652
894
|
* value only — the person owns the state after the first click. */
|
|
653
895
|
readonly defaultOpen?: boolean;
|
|
654
896
|
/**
|
|
655
|
-
* How a `"dictionary"` group is drawn.
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
897
|
+
* How a `"dictionary"` group is drawn. Meaningless for the other three
|
|
898
|
+
* shapes.
|
|
899
|
+
*
|
|
900
|
+
* - `"field"` — the desktop shape: a select-style field reading its chosen
|
|
901
|
+
* values or "Any", which opens the searchable list under it;
|
|
902
|
+
* - `"sheet"` — the PHONE shape: a trigger row reading the same sentence,
|
|
903
|
+
* opening a nested picker sheet with a search box, a recommended band
|
|
904
|
+
* and the rest. The same control the composer's vocabulary picker is, so
|
|
905
|
+
* one dictionary is one gesture on both halves of the product. Needs
|
|
906
|
+
* {@link FacetGroupControlProps.onSetValues} — the sheet commits a whole
|
|
907
|
+
* draft at once, and a per-value toggle cannot apply one; without it the
|
|
908
|
+
* group falls back to `"field"`;
|
|
909
|
+
* - `"inline"` (the default) keeps the list open — a group that is already
|
|
910
|
+
* the only thing on its surface, such as a per-chip sheet.
|
|
660
911
|
*/
|
|
661
|
-
readonly dictionaryMode?: "field" | "inline";
|
|
912
|
+
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
913
|
+
/**
|
|
914
|
+
* Write a slug's chosen values in ONE go — `useFacetPanel`'s `setValues`.
|
|
915
|
+
*
|
|
916
|
+
* `onToggle` reads the current state to flip one value, so N toggles in one
|
|
917
|
+
* tick collapse into the last one; a sheet that commits a draft of several
|
|
918
|
+
* ticks needs the bulk write. Optional so no existing host changes, and
|
|
919
|
+
* only `dictionaryMode: "sheet"` reads it.
|
|
920
|
+
*/
|
|
921
|
+
readonly onSetValues?: (slug: string, values: readonly string[]) => void;
|
|
662
922
|
}
|
|
663
923
|
|
|
664
924
|
export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
@@ -739,21 +999,17 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
|
|
|
739
999
|
{/* Closed means NOT RENDERED, not hidden: a hundred `display:none`
|
|
740
1000
|
checkboxes are still a hundred stops for a screen reader, and the
|
|
741
1001
|
measured rail held 118 of them. */}
|
|
742
|
-
{open &&
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
onToggle={props.onToggle}
|
|
754
|
-
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
755
|
-
/>
|
|
756
|
-
))}
|
|
1002
|
+
{open && shape === "dictionary" && (
|
|
1003
|
+
<DictionaryControl
|
|
1004
|
+
group={group}
|
|
1005
|
+
mode={props.dictionaryMode}
|
|
1006
|
+
onToggle={props.onToggle}
|
|
1007
|
+
{...(props.onSetValues !== undefined
|
|
1008
|
+
? { onSetValues: props.onSetValues }
|
|
1009
|
+
: {})}
|
|
1010
|
+
visible={limit ?? FACET_VISIBLE_OPTIONS}
|
|
1011
|
+
/>
|
|
1012
|
+
)}
|
|
757
1013
|
|
|
758
1014
|
{open && shape !== "dictionary" && (
|
|
759
1015
|
<>
|
|
@@ -105,7 +105,7 @@ import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
|
105
105
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
106
106
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
107
107
|
import { LanguageSelect } from "./LanguageSelect.js";
|
|
108
|
-
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
108
|
+
import { RANGE_ROW_MIN_HEIGHT, RangeFilterRow, RangeRowSkeleton } from "./RangeFilterRow.js";
|
|
109
109
|
import type { ThemeModeProp } from "./types.js";
|
|
110
110
|
|
|
111
111
|
/**
|
|
@@ -315,10 +315,12 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
315
315
|
/**
|
|
316
316
|
* How a DICTIONARY group is drawn. `"field"` is the desktop shape — a
|
|
317
317
|
* select-style field reading its chosen values or "Any", which opens the
|
|
318
|
-
* searchable list; `"
|
|
319
|
-
*
|
|
318
|
+
* searchable list under it; `"sheet"` is the phone's — the same trigger
|
|
319
|
+
* row, opening a nested picker sheet with a search box, a recommended band
|
|
320
|
+
* and the rest, which is the control the composer's vocabulary picker
|
|
321
|
+
* already is; `"inline"` (the default) keeps the list open.
|
|
320
322
|
*/
|
|
321
|
-
readonly dictionaryMode?: "field" | "inline";
|
|
323
|
+
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
/**
|
|
@@ -828,6 +830,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
828
830
|
key={needle === "" ? group.slug : `${group.slug}:match`}
|
|
829
831
|
group={group}
|
|
830
832
|
onToggle={bag.toggle}
|
|
833
|
+
onSetValues={bag.setValues}
|
|
831
834
|
collapsible
|
|
832
835
|
defaultOpen={
|
|
833
836
|
needle !== "" ||
|
|
@@ -873,23 +876,57 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
873
876
|
phone). It is a real filter for the person who wants it, so it
|
|
874
877
|
is not deleted; it is ranked where the chip row already ranks
|
|
875
878
|
it. */}
|
|
876
|
-
{
|
|
879
|
+
{/* The reservation, not just the rows (D361).
|
|
880
|
+
On a live category feed at 1536px this block's arrival was a
|
|
881
|
+
53px jump: `attributeRanges` draws from the CATEGORY SCHEMA
|
|
882
|
+
(`props.categoryFeatures`), and a host that fetches the
|
|
883
|
+
schema alongside the search answer had nothing here at all
|
|
884
|
+
until both landed — no host slot reserved the box, so the
|
|
885
|
+
rail grew under the reader's eye the instant it did.
|
|
886
|
+
|
|
887
|
+
Two things can be unknown at first paint, and each gets its
|
|
888
|
+
own reservation:
|
|
889
|
+
- the SCHEMA itself (`categoryFeatures` undefined) — the
|
|
890
|
+
axis count is unknown, so the fallback is one row's floor,
|
|
891
|
+
a guess rather than nothing;
|
|
892
|
+
- the ANSWER (`bag.state` not yet "ready") with a known
|
|
893
|
+
schema — the axis COUNT is already certain from the
|
|
894
|
+
schema, so the rail draws that many skeleton rows, each
|
|
895
|
+
`RANGE_ROW_MIN_HEIGHT` tall like the real one it will
|
|
896
|
+
become. Same count in both arms, so the swap from
|
|
897
|
+
skeleton to `<RangeFilterRow>` costs no further height. */}
|
|
898
|
+
{props.categoryFeatures === undefined ? (
|
|
877
899
|
<>
|
|
878
900
|
<Divider style={{ margin: 0 }} />
|
|
879
|
-
<
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
{attributeRanges.map((group) => (
|
|
885
|
-
<RangeFilterRow
|
|
886
|
-
key={group.slug}
|
|
887
|
-
group={group}
|
|
888
|
-
onApply={bag.setRange}
|
|
889
|
-
/>
|
|
890
|
-
))}
|
|
891
|
-
</Flex>
|
|
901
|
+
<div
|
|
902
|
+
aria-hidden="true"
|
|
903
|
+
data-testid="search-ranges-attributes-reserve"
|
|
904
|
+
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
905
|
+
/>
|
|
892
906
|
</>
|
|
907
|
+
) : (
|
|
908
|
+
attributeRanges.length > 0 && (
|
|
909
|
+
<>
|
|
910
|
+
<Divider style={{ margin: 0 }} />
|
|
911
|
+
<Flex
|
|
912
|
+
vertical
|
|
913
|
+
gap={spacing[3]}
|
|
914
|
+
data-testid="search-ranges-attributes"
|
|
915
|
+
>
|
|
916
|
+
{bag.state.status === "ready"
|
|
917
|
+
? attributeRanges.map((group) => (
|
|
918
|
+
<RangeFilterRow
|
|
919
|
+
key={group.slug}
|
|
920
|
+
group={group}
|
|
921
|
+
onApply={bag.setRange}
|
|
922
|
+
/>
|
|
923
|
+
))
|
|
924
|
+
: attributeRanges.map((group) => (
|
|
925
|
+
<RangeRowSkeleton key={group.slug} />
|
|
926
|
+
))}
|
|
927
|
+
</Flex>
|
|
928
|
+
</>
|
|
929
|
+
)
|
|
893
930
|
)}
|
|
894
931
|
|
|
895
932
|
{footerBar !== "none" && (
|
|
@@ -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, Select, Typography } from "antd";
|
|
20
|
+
import { Button, Flex, InputNumber, Select, Skeleton, 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";
|
|
@@ -30,6 +30,49 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
|
30
30
|
/** Floor width of one bound field, so two of them fit a 390px phone row. */
|
|
31
31
|
export const RANGE_FIELD_MIN_WIDTH = 96;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* One row's reserved block-size — a label line plus one control row, the two
|
|
35
|
+
* lines every real row draws. Used both as the skeleton row's floor and as
|
|
36
|
+
* the whole reserve when the schema itself is not known yet (D361: a 53px
|
|
37
|
+
* jump inside the rail when the attribute-range block had no box to arrive
|
|
38
|
+
* into, because nothing reserved its height before the answer landed).
|
|
39
|
+
*/
|
|
40
|
+
export const RANGE_ROW_MIN_HEIGHT = 76;
|
|
41
|
+
|
|
42
|
+
/** Floor width of the label placeholder in {@link RangeRowSkeleton} — a
|
|
43
|
+
* named one-off, not a spacing step: it bounds a text skeleton, not a gap. */
|
|
44
|
+
const RANGE_SKELETON_LABEL_WIDTH = 160;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The unrevealed shape of one range row — same two lines, same field widths,
|
|
48
|
+
* no numbers. Drawn for a slug the schema already names while the answer
|
|
49
|
+
* that would fill it (bounds, applied value, unit) has not landed yet, so
|
|
50
|
+
* the rail's box is the row's own final height from the first paint rather
|
|
51
|
+
* than growing into it once the real row replaces this one.
|
|
52
|
+
*/
|
|
53
|
+
export function RangeRowSkeleton(): ReactElement {
|
|
54
|
+
return (
|
|
55
|
+
<Flex
|
|
56
|
+
vertical
|
|
57
|
+
gap={spacing[1]}
|
|
58
|
+
aria-hidden="true"
|
|
59
|
+
data-testid="facet-range-skeleton"
|
|
60
|
+
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
61
|
+
>
|
|
62
|
+
<Skeleton.Input
|
|
63
|
+
active
|
|
64
|
+
size="small"
|
|
65
|
+
style={{ maxWidth: RANGE_SKELETON_LABEL_WIDTH }}
|
|
66
|
+
/>
|
|
67
|
+
<Flex gap={spacing[2]} align="center" wrap>
|
|
68
|
+
<Skeleton.Input active style={{ minWidth: RANGE_FIELD_MIN_WIDTH }} />
|
|
69
|
+
<Skeleton.Input active style={{ minWidth: RANGE_FIELD_MIN_WIDTH }} />
|
|
70
|
+
<Skeleton.Button active />
|
|
71
|
+
</Flex>
|
|
72
|
+
</Flex>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
33
76
|
export interface RangeFilterRowProps {
|
|
34
77
|
readonly group: RangeGroup;
|
|
35
78
|
/** `null` clears the slug's range entirely. */
|
|
@@ -272,14 +272,18 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
272
272
|
* the two frames want opposite shapes and only this component knows which
|
|
273
273
|
* one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
|
|
274
274
|
* that opens the searchable list — a 418-value vocabulary held open in a
|
|
275
|
-
* 280px column is the whole column), the phone sheet gets `"
|
|
276
|
-
*
|
|
275
|
+
* 280px column is the whole column), the phone sheet gets `"sheet"` — the
|
|
276
|
+
* same trigger row, opening a nested picker with a search box, a
|
|
277
|
+
* recommended band and the rest.
|
|
277
278
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
279
|
+
* The phone default was `"inline"`, which drew the axis as a wall of
|
|
280
|
+
* checkboxes with a "Find a value" box over it while the COMPOSER's picker
|
|
281
|
+
* for the very same vocabulary was a trigger and a sheet. One dictionary,
|
|
282
|
+
* two gestures, depending on which half of the product you were in. Set it
|
|
283
|
+
* to override both; `"inline"` is still there for a surface that is
|
|
284
|
+
* already devoted to one group.
|
|
281
285
|
*/
|
|
282
|
-
readonly dictionaryMode?: "field" | "inline";
|
|
286
|
+
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
283
287
|
/**
|
|
284
288
|
* How many groups the rail draws before the rest fold behind "All filters"
|
|
285
289
|
* — see {@link FacetPanelPaneProps.visibleGroups}.
|
|
@@ -455,7 +459,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
455
459
|
|
|
456
460
|
interface SearchPageBodyProps {
|
|
457
461
|
readonly renderCard?: SearchCardRenderer;
|
|
458
|
-
readonly dictionaryMode?: "field" | "inline";
|
|
462
|
+
readonly dictionaryMode?: "field" | "inline" | "sheet";
|
|
459
463
|
readonly visibleGroups?: number | null;
|
|
460
464
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
461
465
|
readonly renderEmptyExits?: () => ReactNode;
|
|
@@ -587,7 +591,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
587
591
|
: // STATIC, not sticky: the rail scrolls with the page, and a bar
|
|
588
592
|
// pinned to the port's floor sat on top of the last groups.
|
|
589
593
|
{ footerBar: "static" as const })}
|
|
590
|
-
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "
|
|
594
|
+
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "sheet" : "field")}
|
|
591
595
|
// `??` would treat an explicit `null` ("never fold") the same as
|
|
592
596
|
// "not set": `visibleGroups` uses `null` as a real value, unlike
|
|
593
597
|
// `dictionaryMode` above, so only `undefined` falls through.
|
package/src/default/index.ts
CHANGED
|
@@ -89,6 +89,15 @@ export interface FacetPanelBag {
|
|
|
89
89
|
readonly activeFilters: number;
|
|
90
90
|
|
|
91
91
|
toggle(slug: string, value: string): void;
|
|
92
|
+
/**
|
|
93
|
+
* Write one slug's chosen values WHOLE — the bulk half of `toggle`.
|
|
94
|
+
*
|
|
95
|
+
* `toggle` reads the state it flips, so several of them in one tick
|
|
96
|
+
* collapse into the last. A control that holds a draft and applies it once
|
|
97
|
+
* — the phone's dictionary sheet — needs to say the whole list, and a
|
|
98
|
+
* storefront replaying a saved search needs the same call.
|
|
99
|
+
*/
|
|
100
|
+
setValues(slug: string, values: readonly string[]): void;
|
|
92
101
|
setRange(slug: string, range: SearchRange | null): void;
|
|
93
102
|
clear(slug: string): void;
|
|
94
103
|
clearAll(): void;
|
|
@@ -224,6 +233,7 @@ export function useFacetPanel(props: {
|
|
|
224
233
|
envelope.data.degraded.includes(FACET_PLAN_EVIDENCE),
|
|
225
234
|
activeFilters,
|
|
226
235
|
toggle: toggleFilter,
|
|
236
|
+
setValues: setFilter,
|
|
227
237
|
setRange,
|
|
228
238
|
clear: (slug) => {
|
|
229
239
|
setFilter(slug, []);
|
package/src/i18n/es.ts
CHANGED
|
@@ -93,6 +93,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
93
93
|
"search.facets.dictionary_empty": "Ningún valor coincide con esto",
|
|
94
94
|
"search.facets.dictionary_chosen": "Elegido",
|
|
95
95
|
"search.facets.dictionary_any": "Cualquiera",
|
|
96
|
+
"search.facets.dictionary_recommended": "Recomendados",
|
|
97
|
+
"search.facets.dictionary_all_values": "Todos los valores",
|
|
98
|
+
"search.facets.dictionary_done": "Listo",
|
|
96
99
|
"search.facets.all_filters": "Todos los filtros ({count})",
|
|
97
100
|
"search.facets.range_bounds": "de {min} a {max}",
|
|
98
101
|
"search.facets.popular_all": "Todos",
|