@stapel/search-react 0.39.0 → 0.40.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 +113 -0
- package/README.md +85 -6
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +31 -4
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +24 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -6
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +33 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +32 -5
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +76 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +132 -45
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +4 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +2 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/swatches.d.ts +44 -0
- package/dist/default/swatches.d.ts.map +1 -0
- package/dist/default/swatches.js +200 -0
- package/dist/default/swatches.js.map +1 -0
- package/llms.txt +2 -2
- package/manifest.json +3 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +39 -0
- package/src/default/FacetPanelPane.tsx +61 -5
- package/src/default/SearchPage.tsx +72 -1
- package/src/default/SearchResultsPane.tsx +134 -5
- package/src/default/index.ts +9 -0
- package/src/default/swatches.ts +214 -0
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
|
|
3
3
|
"package": "@stapel/search-react",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.40.0",
|
|
5
5
|
"backend": {
|
|
6
6
|
"module": "stapel-search",
|
|
7
7
|
"contract": ">=0.16 <0.17"
|
|
@@ -582,6 +582,8 @@
|
|
|
582
582
|
"phone",
|
|
583
583
|
"catalogue-leaf",
|
|
584
584
|
"under-a-header",
|
|
585
|
+
"toolbar-not-pinned",
|
|
586
|
+
"rail-on-a-panel",
|
|
585
587
|
"system-scrollbar",
|
|
586
588
|
"legacy-rhythm",
|
|
587
589
|
"filters-header-navigates",
|
package/nav-manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
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": "14.5 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, 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, then 29.25 → 30.5 KB for the panel that draws all of it: groups and ranges as one ordered sequence with one «Apply» for the panel rather than one per row, the empty-group heading rule, `categoryFilter={false}` and the `resultsLead` slot for a page reached by walking the catalogue, `PopularValues columns=\\\"responsive\\\"` on a container query, `SortSelect` annotating a blocked option at every width, a loading arm that covers the whole pane (0.34 CLS on a live host), and the chip row reserving its own box (a further 0.045). Measured with dependencies held constant, this package's src at the commit before that work and then at it: 29.05 -> 30.16 KB — 1.11 KB, and every part of it is a shift or a mislabelled control a host was living with, then 30.5 -> 31 KB for `<SearchResultsPane columns>` and its pass-through on `<SearchPage resultsColumns>`: a fixed track count or a per-breakpoint map, emitted as a hoisted container-query sheet (the results block is the window minus a 280px rail, so a media query would answer about a width the cards never have). Measured with dependencies held constant, this package's src before and after: 30.40 -> 30.65 KB — 250 B, and what it replaces is a host's `!important` rule against a grid declaration it could not read back, then 31 -> 31.25 KB for the segmented partition being antd's `Segmented` rather than a row of buttons wearing a border: the walker read `data-variant=\"segmented\"` with `role=\"radiogroup\"` and found `.ant-segmented` zero times and `input[type=radio]` zero times (D304), so the cells are the design system's now — real radios under one name, the browser's own arrow keys and Tab stop. Measured with dependencies held constant, this package's src before and after: 30.95 -> 31.05 KB — 100 B, against ~30 lines of hand-rolled joined-row geometry deleted, then 31.25 -> 31.5 KB for the two PINNING seams: `<SearchPage railTop>` (the rail's sticky offset and its height cap moved together, so a rail under a 64px header still ends at the foot of the window) and `<SearchResultsPane stickyToolbar>` with the toolbar's own element in both header shapes. Measured with dependencies held constant, this package's src before and after: 31.05 -> 31.2 KB — 150 B, and what it replaces in one deployment is an `!important` over the pair's own inline geometry, a `:has()`, a `display: contents` and this pair's rail breakpoint restated in the host's media query, then 31.5 -> 31.75 KB for the rail saying when its answer is IN FLIGHT: `FacetPanelBag.refreshing` off `loadStateFromQuery(…, { keepPrevious })`, `data-facets-refreshing` on the rail, and each facet group standing on its own last measured height (a `min-block-size` floor held in a ref) until the new answer lands. Measured with dependencies held constant, this package's src before and after: 31.43 -> 31.51 KB — 80 B, against 0.0586 CLS on a partition press (p43), where the groups survive and RESIZE, then 31.75 -> 32 KB for the FIRST MOUNT, which had none of that: `refreshing` is never true on a cold load, so nothing above reached the one pass a plain load is made of. Three parts. `<SearchPage categoryFeaturesPending>` / `<FacetPanelPane categoryFeaturesPending>` is the third state `categoryFeatures` never had — undefined means both \"no schema\" and \"not yet\", and on a category leaf the schema is a SECOND read, so the panel drew the rail from the answer alone and then drew it again when the schema landed: on the live cars leaf make and model went from three-row checkbox lists to one-row dictionary fields, condition and colour from checkboxes to pills, and `orderFacetGroupsBySchema` reordered all of them (p41, 0.0586 CLS on a plain load of `/c/transport-avtomobili`, make -152px and model -76px). Told the schema is coming, the panel keeps the box it already reserves and draws the rail ONCE. Second, every group now stands on a DECLARED box from the frame it mounts in (`facetGroupReservedHeight` — heading, rows and fold per shape), with its measured height preferred only while an answer is in flight. Third, `<SearchPage filtersHeaderReserve>` puts the host's own band above the rail in flow from the first frame, for the partition row that is two chained catalogue reads behind the answer. Measured with dependencies held constant, this package's src before and after: 30.77 -> 31.14 KB — 366 B, then 32 -> 32.25 KB for the filter sheet's open state becoming the HOST's: `<SearchPage filtersOpen>` / `onFiltersOpenChange(open, reason)` as React's usual controlled pair, and `filtersHeader` accepting a function handed `{ closeFilters, open }`. What it costs is the four call sites naming WHY the sheet moved (`open`, `apply`, `dismiss`, `consumer`) and the slot's function arm; what it buys is a header whose own control NAVIGATES being able to take the sheet down on the same press — the page published only `defaultFiltersOpen`, so a partition chip inside the sheet left the drawer standing over the page it had just opened. Measured with dependencies held constant, this package's src before and after: 31.14 -> 31.26 KB — 120 B, then 32.25 -> 32.5 KB for the two levers a storefront could not reach: `<SearchPage footerBar>` (the rail's footer bar writes its own `display` inline, so a consumer stylesheet could only suppress it with an `!important` its own gate forbids, and this page hard-coded the column's value) and `PartitionChild.count`, which the chip draws itself in the muted weight the facet rows use instead of a host welding the number onto the name. Measured with dependencies held constant, this package's src before and after: 32.01 -> 32.10 KB — 90 B, against a string-joining hack and an unreachable bar, then 32.5 -> 32.75 KB for THE THREE BOXES A STOREFRONT WAS HOLDING FOR THIS PAIR. The phone chip row's reserve is `chipRowMinHeight(token.controlHeight)` instead of a `44 + …` constant: the touch floor raises the chips to 44 only BELOW the tablet breakpoint, so from 768px up the reserve stood 12px taller than the 40px row and the results pane ROSE when the row landed (0.0725 CLS on a category leaf at 768, against 0.00016 at 390). `<SortSelect compact>` reserves the width of the longest label it can display, measured by the browser through an `aria-hidden` sizer stacked with the select in one grid cell, instead of `minWidth: 0` inline and a control that GREW 115px when the answer named the sort. And `<SearchResultsPane>` names its own root (`data-testid=\"search-results-pane\"`) and takes `reserve` — `<SearchPage resultsReserve>` — for the box the feed arrives into, which a host was holding with `#search-page > :last-child`. Measured with dependencies held constant, this package's src before and after: 32.10 -> 32.25 KB — 150 B, against a hand-guessed height, a sibling-count selector and two stylesheet rules a consumer can now delete. 32.75 KB HOLDS for the rail's own scrollbar and the block rhythm (owner's walk of the storefront, dark theme): measured 32539 B against the 32.75 KB line, 290 B over the 32249 B this note last recorded, and 211 B of room left. The rail stays its own scroll container — filters that stay put while the results move under them is the whole point of it — and `railScrollbar` names whose BAR draws in the gutter: `\"styled\"` (the new default) is a hoisted rule set in both vendor forms, a 6px track with no arrows and no track fill and a thumb that is transparent at rest and arrives from the tokens on hover or focus-within, standing always under `(pointer: coarse)` where neither fires; `\"system\"` hands the port back to the platform and mounts no sheet at all. The system bar was never a decision, which is why the default is the new arm. The rest is `blockRhythm`: one gap for every block on this page from `var(--stapel-block-gap)` / `var(--stapel-block-gap-compact)` with each block's outer margin reset, in place of the flat `spacing[4]` the root `<Flex>` wrote inline. A budget is not raised for a change that fits under the line it already has. 32.75 -> 33.25 KB for THE POINTER THAT WAS A PARTITION CHIP (owner's read of the stand at 0.38.0). Measured with dependencies held constant, this package's src before this change and then at it: 32539 -> 32779 B — 240 B, 29 of them over the old line, and a ceiling that fails on 29 B of a shipped defect fix is a gate proving nothing. `PartitionChild.linked`/`href` and `<PartitionChips linkedChildren>` split the row's items ONCE, above everything that reads them: the cells, the roving stop, the value lookup and the arrow keys see only the SECTIONS, so a pointer cannot be a radio, cannot carry a count and cannot be the chosen partition — the live row read `All | New 0 | Used 3 | Car rental 0`, where the last zero counted a category that is not a section of this template at all. The rest is the pointer's own chip: an outlined pill that is a real `<a href>` to the target, its inline arrow glyph (drawn here, like every other glyph in this skin — this package ships no icon set), and the row that carries them OUTSIDE the radiogroup, because a `role=\"radiogroup\"` containing a link announces a choice with an option nobody can choose. 33.25 KB leaves 471 B",
|
|
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, then 29.25 → 30.5 KB for the panel that draws all of it: groups and ranges as one ordered sequence with one «Apply» for the panel rather than one per row, the empty-group heading rule, `categoryFilter={false}` and the `resultsLead` slot for a page reached by walking the catalogue, `PopularValues columns=\\\"responsive\\\"` on a container query, `SortSelect` annotating a blocked option at every width, a loading arm that covers the whole pane (0.34 CLS on a live host), and the chip row reserving its own box (a further 0.045). Measured with dependencies held constant, this package's src at the commit before that work and then at it: 29.05 -> 30.16 KB — 1.11 KB, and every part of it is a shift or a mislabelled control a host was living with, then 30.5 -> 31 KB for `<SearchResultsPane columns>` and its pass-through on `<SearchPage resultsColumns>`: a fixed track count or a per-breakpoint map, emitted as a hoisted container-query sheet (the results block is the window minus a 280px rail, so a media query would answer about a width the cards never have). Measured with dependencies held constant, this package's src before and after: 30.40 -> 30.65 KB — 250 B, and what it replaces is a host's `!important` rule against a grid declaration it could not read back, then 31 -> 31.25 KB for the segmented partition being antd's `Segmented` rather than a row of buttons wearing a border: the walker read `data-variant=\"segmented\"` with `role=\"radiogroup\"` and found `.ant-segmented` zero times and `input[type=radio]` zero times (D304), so the cells are the design system's now — real radios under one name, the browser's own arrow keys and Tab stop. Measured with dependencies held constant, this package's src before and after: 30.95 -> 31.05 KB — 100 B, against ~30 lines of hand-rolled joined-row geometry deleted, then 31.25 -> 31.5 KB for the two PINNING seams: `<SearchPage railTop>` (the rail's sticky offset and its height cap moved together, so a rail under a 64px header still ends at the foot of the window) and `<SearchResultsPane stickyToolbar>` with the toolbar's own element in both header shapes. Measured with dependencies held constant, this package's src before and after: 31.05 -> 31.2 KB — 150 B, and what it replaces in one deployment is an `!important` over the pair's own inline geometry, a `:has()`, a `display: contents` and this pair's rail breakpoint restated in the host's media query, then 31.5 -> 31.75 KB for the rail saying when its answer is IN FLIGHT: `FacetPanelBag.refreshing` off `loadStateFromQuery(…, { keepPrevious })`, `data-facets-refreshing` on the rail, and each facet group standing on its own last measured height (a `min-block-size` floor held in a ref) until the new answer lands. Measured with dependencies held constant, this package's src before and after: 31.43 -> 31.51 KB — 80 B, against 0.0586 CLS on a partition press (p43), where the groups survive and RESIZE, then 31.75 -> 32 KB for the FIRST MOUNT, which had none of that: `refreshing` is never true on a cold load, so nothing above reached the one pass a plain load is made of. Three parts. `<SearchPage categoryFeaturesPending>` / `<FacetPanelPane categoryFeaturesPending>` is the third state `categoryFeatures` never had — undefined means both \"no schema\" and \"not yet\", and on a category leaf the schema is a SECOND read, so the panel drew the rail from the answer alone and then drew it again when the schema landed: on the live cars leaf make and model went from three-row checkbox lists to one-row dictionary fields, condition and colour from checkboxes to pills, and `orderFacetGroupsBySchema` reordered all of them (p41, 0.0586 CLS on a plain load of `/c/transport-avtomobili`, make -152px and model -76px). Told the schema is coming, the panel keeps the box it already reserves and draws the rail ONCE. Second, every group now stands on a DECLARED box from the frame it mounts in (`facetGroupReservedHeight` — heading, rows and fold per shape), with its measured height preferred only while an answer is in flight. Third, `<SearchPage filtersHeaderReserve>` puts the host's own band above the rail in flow from the first frame, for the partition row that is two chained catalogue reads behind the answer. Measured with dependencies held constant, this package's src before and after: 30.77 -> 31.14 KB — 366 B, then 32 -> 32.25 KB for the filter sheet's open state becoming the HOST's: `<SearchPage filtersOpen>` / `onFiltersOpenChange(open, reason)` as React's usual controlled pair, and `filtersHeader` accepting a function handed `{ closeFilters, open }`. What it costs is the four call sites naming WHY the sheet moved (`open`, `apply`, `dismiss`, `consumer`) and the slot's function arm; what it buys is a header whose own control NAVIGATES being able to take the sheet down on the same press — the page published only `defaultFiltersOpen`, so a partition chip inside the sheet left the drawer standing over the page it had just opened. Measured with dependencies held constant, this package's src before and after: 31.14 -> 31.26 KB — 120 B, then 32.25 -> 32.5 KB for the two levers a storefront could not reach: `<SearchPage footerBar>` (the rail's footer bar writes its own `display` inline, so a consumer stylesheet could only suppress it with an `!important` its own gate forbids, and this page hard-coded the column's value) and `PartitionChild.count`, which the chip draws itself in the muted weight the facet rows use instead of a host welding the number onto the name. Measured with dependencies held constant, this package's src before and after: 32.01 -> 32.10 KB — 90 B, against a string-joining hack and an unreachable bar, then 32.5 -> 32.75 KB for THE THREE BOXES A STOREFRONT WAS HOLDING FOR THIS PAIR. The phone chip row's reserve is `chipRowMinHeight(token.controlHeight)` instead of a `44 + …` constant: the touch floor raises the chips to 44 only BELOW the tablet breakpoint, so from 768px up the reserve stood 12px taller than the 40px row and the results pane ROSE when the row landed (0.0725 CLS on a category leaf at 768, against 0.00016 at 390). `<SortSelect compact>` reserves the width of the longest label it can display, measured by the browser through an `aria-hidden` sizer stacked with the select in one grid cell, instead of `minWidth: 0` inline and a control that GREW 115px when the answer named the sort. And `<SearchResultsPane>` names its own root (`data-testid=\"search-results-pane\"`) and takes `reserve` — `<SearchPage resultsReserve>` — for the box the feed arrives into, which a host was holding with `#search-page > :last-child`. Measured with dependencies held constant, this package's src before and after: 32.10 -> 32.25 KB — 150 B, against a hand-guessed height, a sibling-count selector and two stylesheet rules a consumer can now delete. 32.75 KB HOLDS for the rail's own scrollbar and the block rhythm (owner's walk of the storefront, dark theme): measured 32539 B against the 32.75 KB line, 290 B over the 32249 B this note last recorded, and 211 B of room left. The rail stays its own scroll container — filters that stay put while the results move under them is the whole point of it — and `railScrollbar` names whose BAR draws in the gutter: `\"styled\"` (the new default) is a hoisted rule set in both vendor forms, a 6px track with no arrows and no track fill and a thumb that is transparent at rest and arrives from the tokens on hover or focus-within, standing always under `(pointer: coarse)` where neither fires; `\"system\"` hands the port back to the platform and mounts no sheet at all. The system bar was never a decision, which is why the default is the new arm. The rest is `blockRhythm`: one gap for every block on this page from `var(--stapel-block-gap)` / `var(--stapel-block-gap-compact)` with each block's outer margin reset, in place of the flat `spacing[4]` the root `<Flex>` wrote inline. A budget is not raised for a change that fits under the line it already has. 32.75 -> 33.25 KB for THE POINTER THAT WAS A PARTITION CHIP (owner's read of the stand at 0.38.0). Measured with dependencies held constant, this package's src before this change and then at it: 32539 -> 32779 B — 240 B, 29 of them over the old line, and a ceiling that fails on 29 B of a shipped defect fix is a gate proving nothing. `PartitionChild.linked`/`href` and `<PartitionChips linkedChildren>` split the row's items ONCE, above everything that reads them: the cells, the roving stop, the value lookup and the arrow keys see only the SECTIONS, so a pointer cannot be a radio, cannot carry a count and cannot be the chosen partition — the live row read `All | New 0 | Used 3 | Car rental 0`, where the last zero counted a category that is not a section of this template at all. The rest is the pointer's own chip: an outlined pill that is a real `<a href>` to the target, its inline arrow glyph (drawn here, like every other glyph in this skin — this package ships no icon set), and the row that carries them OUTSIDE the radiogroup, because a `role=\"radiogroup\"` containing a link announces a choice with an option nobody can choose. 33.25 KB leaves 471 B, then 33.25 -> 34 KB for THE TWO THINGS THE CLOSING-WAVE CENSUS FOUND ON THE FEED AND THE RAIL. Measured with dependencies held constant, this package's src before this change and then at it: 32779 -> 33584 B — 805 B, 334 of them over the old line. First, `<SearchPage toolbarSticky>` (default `true`): the results toolbar pins itself at `railTop` — the same edge the filter rail already clears, so there is no second number to keep in step — through a hoisted `@media (pointer: fine)` rule set, because the pin has to be gated on a pointer and a media query cannot be written in a `style` attribute. `stickyToolbar` shipped a release ago and no deployment turned it on, which is a feature nobody has; the reference pins its sort bar once a reader has scrolled into the results (REPORT §24, Surface 2) and this page now does too, on a desktop only — a pinned bar over a 390px viewport spends the fold on chrome. The row also states its own box from the first frame (`toolbarRowMinHeight(token.controlHeight)`, the discipline `chipRowMinHeight` is written under), so its height cannot change around the moment the rule engages. Second, the colour SWATCH: a facet whose axis is a colour (`isColorAxis` — the slug's head, the address key beside it, and `axis_role` the day the canon grows a colour one) draws a filled dot beside every value whose code it can resolve to an actual colour (`swatchColor` — a design-system colour role first, then CSS's own keyword vocabulary, then a hex code a catalogue spelled out itself), and NOTHING beside the rest. Most of the weight is that keyword list, and it is the whole point of the feature: it is the vocabulary in which \"this code names a colour\" is a fact rather than a guess, and without it the only alternatives are a hue table invented for one catalogue's transliterations or a grey placeholder saying every value is the same colour. 34 KB leaves 416 B. 34 KB HOLDS for the owner's tidiness probe on the stand (dark theme): measured 33661 B against the 34 KB line, 77 B over the 33584 B this note last recorded, and 339 B of room left. Three fills, all of them this pair drawing something the page had already decided. `<SearchPage railSurface>` / `<FacetPanelPane railSurface>` (default `\"flat\"`, `\"panel\"` restores the old arm): the filter panel's body painted the raised container ground and read as a 270 x 1539 filled slab with no radius and no border, standing on the page ground for the whole height of the feed — flat draws the controls and only the text colour a bare surface drops (`var(--stapel-text)`), the same answer `categories-react` gave for its grid, strip and breadcrumbs. The rail's footer bar stopped choosing a colour: it painted antd's `colorBgContainer` in both arms, and now paints the panel's OWN token and only in the pinned arm, which is the one with a scroll port under it — the static arm paints nothing and keeps its hairline. And `resultsHeader`'s wrapper is `display: contents`: it was mounted on the PROP rather than on what the prop rendered, so a host whose header said nothing still put a 1392 x 0 element in the block-rhythm column, which is charged a gap on BOTH sides — 64px between two real blocks where 32 is declared, on every feed page, plus a `:empty` stand-in rule in the consumer's stylesheet that can now be deleted. A budget is not raised for a change that fits under the line it already has",
|
|
61
61
|
"path": "dist/default/index.js",
|
|
62
|
-
"limit": "
|
|
62
|
+
"limit": "34 KB"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"name": "router — the react-router binding is opt-in; the main entry must never pull a router",
|
|
@@ -115,9 +115,9 @@
|
|
|
115
115
|
"size-limit": "^11.2.0",
|
|
116
116
|
"typescript": "^5.8.3",
|
|
117
117
|
"vitest": "^3.2.4",
|
|
118
|
-
"@stapel/image": "^0.4.2",
|
|
119
|
-
"@stapel/core": "^0.26.1",
|
|
120
118
|
"@stapel/attributes-react": "^0.17.0",
|
|
119
|
+
"@stapel/core": "^0.26.1",
|
|
120
|
+
"@stapel/image": "^0.4.2",
|
|
121
121
|
"@stapel/showcase": "^0.3.0",
|
|
122
122
|
"@stapel/tokens": "^0.8.0",
|
|
123
123
|
"@stapel/tokens-antd": "^0.18.0"
|
|
@@ -67,6 +67,7 @@ import type { CSSProperties, ReactElement } from "react";
|
|
|
67
67
|
import { Button, Checkbox, Flex, Input, Typography } from "antd";
|
|
68
68
|
import { useT } from "@stapel/core";
|
|
69
69
|
import { SkinPickerSheet } from "@stapel/tokens-antd/skin";
|
|
70
|
+
import { SWATCH_SIZE, facetSwatch } from "./swatches.js";
|
|
70
71
|
import type { PickerGroup, PickerOption } from "@stapel/tokens-antd/skin";
|
|
71
72
|
import { controls, cssVar, radii, spacing } from "@stapel/tokens";
|
|
72
73
|
import { featureConfig, featureType } from "@stapel/attributes-react";
|
|
@@ -342,6 +343,42 @@ function OptionCount(props: {
|
|
|
342
343
|
/** The indent one level of a hierarchical facet is drawn with. */
|
|
343
344
|
const NEST_STEP = spacing[5];
|
|
344
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The colour a value IS, drawn beside the word for it — see `./swatches.ts`.
|
|
348
|
+
*
|
|
349
|
+
* `aria-hidden`, and it never replaces the label: a dot is a second way to
|
|
350
|
+
* read a row that already reads, so a screen reader and a monochrome display
|
|
351
|
+
* lose nothing. The hairline is the panel's own border role, because a white
|
|
352
|
+
* swatch on a white rail is otherwise an empty hole.
|
|
353
|
+
*/
|
|
354
|
+
function Swatch(props: {
|
|
355
|
+
readonly group: FacetGroup;
|
|
356
|
+
readonly value: string;
|
|
357
|
+
}): ReactElement | null {
|
|
358
|
+
const color = facetSwatch(props.group, props.value);
|
|
359
|
+
if (color === null) return null;
|
|
360
|
+
return (
|
|
361
|
+
<span
|
|
362
|
+
aria-hidden="true"
|
|
363
|
+
data-testid={`facet-swatch-${props.group.slug}-${props.value}`}
|
|
364
|
+
data-swatch={color}
|
|
365
|
+
style={{
|
|
366
|
+
display: "inline-block",
|
|
367
|
+
inlineSize: SWATCH_SIZE,
|
|
368
|
+
blockSize: SWATCH_SIZE,
|
|
369
|
+
flex: "0 0 auto",
|
|
370
|
+
borderRadius: radii.full,
|
|
371
|
+
background: color,
|
|
372
|
+
border: `1px solid ${cssVar("border")}`,
|
|
373
|
+
// The dot sits ON the text line rather than on the box's baseline,
|
|
374
|
+
// which is what keeps a 12px circle centred against a 14px label.
|
|
375
|
+
verticalAlign: "-0.125em",
|
|
376
|
+
marginInlineEnd: spacing[1],
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
345
382
|
function CheckboxRow(props: {
|
|
346
383
|
readonly group: FacetGroup;
|
|
347
384
|
readonly node: FacetOptionNode;
|
|
@@ -369,6 +406,7 @@ function CheckboxRow(props: {
|
|
|
369
406
|
props.onToggle(group.slug, node.option.value);
|
|
370
407
|
}}
|
|
371
408
|
>
|
|
409
|
+
<Swatch group={group} value={node.option.value} />
|
|
372
410
|
{node.option.label}
|
|
373
411
|
</Checkbox>
|
|
374
412
|
<OptionCount group={group} option={node.option} />
|
|
@@ -406,6 +444,7 @@ function OptionPill(props: {
|
|
|
406
444
|
props.onToggle(group.slug, option.value);
|
|
407
445
|
}}
|
|
408
446
|
>
|
|
447
|
+
<Swatch group={group} value={option.value} />
|
|
409
448
|
{option.count === null ? option.label : `${option.label} ${option.count}`}
|
|
410
449
|
</Button>
|
|
411
450
|
);
|
|
@@ -96,7 +96,7 @@ import {
|
|
|
96
96
|
LoadList,
|
|
97
97
|
SkinTheme,
|
|
98
98
|
} from "@stapel/tokens-antd/skin";
|
|
99
|
-
import { spacing } from "@stapel/tokens";
|
|
99
|
+
import { cssVar, spacing } from "@stapel/tokens";
|
|
100
100
|
import { featureName } from "@stapel/attributes-react";
|
|
101
101
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
102
102
|
import type { SearchGeo } from "../api/types.js";
|
|
@@ -226,7 +226,32 @@ export interface GeoFilterSlotProps {
|
|
|
226
226
|
readonly onChange: (geo: SearchGeo | null) => void;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* WHAT THE FILTER PANEL'S OWN BODY PAINTS — see
|
|
231
|
+
* {@link FacetPanelPaneProps.railSurface}.
|
|
232
|
+
*/
|
|
233
|
+
export type SearchRailSurface = "flat" | "panel";
|
|
234
|
+
|
|
229
235
|
export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
236
|
+
/**
|
|
237
|
+
* WHAT THE PANEL'S OWN BODY PAINTS. Default `"flat"`.
|
|
238
|
+
*
|
|
239
|
+
* - `"flat"` — nothing. The panel is a column of controls standing on the
|
|
240
|
+
* page's own ground, and it takes only the text colour it needs
|
|
241
|
+
* (`var(--stapel-text)`), which a bare surface does not set;
|
|
242
|
+
* - `"panel"` — the raised container ground this pane painted until now.
|
|
243
|
+
*
|
|
244
|
+
* The default changed, and the measurement is why: on the stand's dark theme
|
|
245
|
+
* the rail was a **270 x 1539** filled slab with no radius and no border,
|
|
246
|
+
* standing on the page ground — a card shape with none of a card's edges,
|
|
247
|
+
* running the whole height of the feed beside it. The same read produced the
|
|
248
|
+
* same verdict for `categories-react`'s grid, strip and breadcrumbs, and the
|
|
249
|
+
* answer there was the same one: draw the controls, not a box around them.
|
|
250
|
+
*
|
|
251
|
+
* `"panel"` is the old arm kept whole, for a deployment whose page ground is
|
|
252
|
+
* an image or whose layout genuinely wants the filters on their own sheet.
|
|
253
|
+
*/
|
|
254
|
+
readonly railSurface?: SearchRailSurface;
|
|
230
255
|
/** The category's feature schema — the source of option LABELS, of which
|
|
231
256
|
* slugs get a numeric range row, and of which slugs are a filter at all
|
|
232
257
|
* (`isFacetableFeature`: an `imei` is counted and is not one). */
|
|
@@ -407,6 +432,9 @@ function RailFooterBar(props: {
|
|
|
407
432
|
/** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
|
|
408
433
|
* after the last group. See {@link FacetPanelPaneProps.footerBar}. */
|
|
409
434
|
readonly position: "sticky" | "static";
|
|
435
|
+
/** What the panel around it paints, so a pinned bar takes the SAME ground
|
|
436
|
+
* rather than deciding one of its own. See {@link FacetPanelPaneProps.railSurface}. */
|
|
437
|
+
readonly railSurface: SearchRailSurface;
|
|
410
438
|
}): ReactElement | null {
|
|
411
439
|
const t = useT();
|
|
412
440
|
const tPlural = useTPlural();
|
|
@@ -430,9 +458,25 @@ function RailFooterBar(props: {
|
|
|
430
458
|
data-testid="facets-footer-bar"
|
|
431
459
|
data-position={props.position}
|
|
432
460
|
style={{
|
|
433
|
-
...(props.position === "sticky"
|
|
434
|
-
|
|
435
|
-
|
|
461
|
+
...(props.position === "sticky"
|
|
462
|
+
? {
|
|
463
|
+
position: "sticky",
|
|
464
|
+
bottom: 0,
|
|
465
|
+
/* THE GROUND IT IS ON, and only where it has to be opaque.
|
|
466
|
+
The bar used to paint antd's `colorBgContainer` in BOTH arms:
|
|
467
|
+
a second opinion about a colour its parent already decided,
|
|
468
|
+
and — since the panel's own body went flat — a lighter strip
|
|
469
|
+
standing across the foot of the rail on the stand's dark
|
|
470
|
+
theme. It now paints the same token the panel does and only
|
|
471
|
+
in the arm that is pinned over its own scroll port, where a
|
|
472
|
+
transparent floor lets the options read THROUGH it. The
|
|
473
|
+
static arm has nothing scrolling under it and paints
|
|
474
|
+
nothing. */
|
|
475
|
+
background: cssVar(
|
|
476
|
+
props.railSurface === "panel" ? "surface-raised" : "surface"
|
|
477
|
+
),
|
|
478
|
+
}
|
|
479
|
+
: {}),
|
|
436
480
|
borderBlockStart: `1px solid ${token.colorSplit}`,
|
|
437
481
|
paddingBlockStart: spacing[2],
|
|
438
482
|
display: "flex",
|
|
@@ -633,8 +677,19 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
633
677
|
? "none"
|
|
634
678
|
: props.footerBar;
|
|
635
679
|
|
|
680
|
+
/* The panel's own ground — see `railSurface`. `"bare"` paints NOTHING, text
|
|
681
|
+
colour included, so the flat arm states the one property it still needs;
|
|
682
|
+
the theme's own custom property, so it follows the brand and the dark side
|
|
683
|
+
rather than freezing whichever mode mounted first. */
|
|
684
|
+
const railSurface: SearchRailSurface = props.railSurface ?? "flat";
|
|
685
|
+
|
|
636
686
|
return (
|
|
637
|
-
<SkinTheme
|
|
687
|
+
<SkinTheme
|
|
688
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
689
|
+
{...(railSurface === "flat"
|
|
690
|
+
? { surface: "bare" as const, style: { color: cssVar("text") } }
|
|
691
|
+
: {})}
|
|
692
|
+
>
|
|
638
693
|
<FacetPanel
|
|
639
694
|
{...(props.categoryFeatures !== undefined
|
|
640
695
|
? { categoryFeatures: props.categoryFeatures }
|
|
@@ -1155,6 +1210,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
1155
1210
|
activeFilters={bag.activeFilters}
|
|
1156
1211
|
clearAll={bag.clearAll}
|
|
1157
1212
|
position={footerBar}
|
|
1213
|
+
railSurface={railSurface}
|
|
1158
1214
|
/>
|
|
1159
1215
|
)}
|
|
1160
1216
|
</Flex>
|
|
@@ -82,6 +82,7 @@ import type {
|
|
|
82
82
|
CategoryFilterSlotProps,
|
|
83
83
|
FacetPanelPaneProps,
|
|
84
84
|
GeoFilterSlotProps,
|
|
85
|
+
SearchRailSurface,
|
|
85
86
|
} from "./FacetPanelPane.js";
|
|
86
87
|
import { FilterChips } from "./FilterChips.js";
|
|
87
88
|
import { LocationSummaryLine } from "./LocationSummaryLine.js";
|
|
@@ -394,6 +395,9 @@ export function railStyle(top: number | string | undefined): CSSProperties {
|
|
|
394
395
|
* cannot push the grid wider than its column. */
|
|
395
396
|
const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
396
397
|
|
|
398
|
+
/** A wrapper that names a slot without occupying one — see `resultsHeader`. */
|
|
399
|
+
const CONTENTS_BOX: CSSProperties = { display: "contents" };
|
|
400
|
+
|
|
397
401
|
/* ── THE RHYTHM: ONE GAP BETWEEN BLOCKS, SAID ONCE ─────────────────────────
|
|
398
402
|
*
|
|
399
403
|
* This page is an assembly of BLOCKS — the query box, the breadcrumb, the
|
|
@@ -785,6 +789,16 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
785
789
|
* page was measured on.
|
|
786
790
|
*/
|
|
787
791
|
readonly railScrollbar?: SearchRailScrollbar;
|
|
792
|
+
/**
|
|
793
|
+
* WHAT THE FILTER PANEL'S OWN BODY PAINTS. Default `"flat"`.
|
|
794
|
+
*
|
|
795
|
+
* Handed straight to {@link FacetPanelPaneProps.railSurface}, in the column
|
|
796
|
+
* and in the phone sheet alike. `"flat"` draws the controls and no box
|
|
797
|
+
* around them (the stand's dark theme read the old ground as a 270 x 1539
|
|
798
|
+
* filled slab with no radius and no border, standing on the page ground);
|
|
799
|
+
* `"panel"` restores the raised container this page painted until now.
|
|
800
|
+
*/
|
|
801
|
+
readonly railSurface?: SearchRailSurface;
|
|
788
802
|
/**
|
|
789
803
|
* WHERE the space between this page's blocks comes from. Default `"token"`.
|
|
790
804
|
*
|
|
@@ -822,6 +836,28 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
822
836
|
* the layout put under their thumb.
|
|
823
837
|
*/
|
|
824
838
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
839
|
+
/**
|
|
840
|
+
* PIN the results toolbar by default, on a fine pointer, at {@link railTop}.
|
|
841
|
+
* Default `true`.
|
|
842
|
+
*
|
|
843
|
+
* {@link stickyToolbar} is the same pin asked for explicitly, and it existed
|
|
844
|
+
* for a release without a single deployment turning it on — a default nobody
|
|
845
|
+
* sets is a feature nobody has. The reference pins its sort bar once a reader
|
|
846
|
+
* has scrolled into the results (REPORT §24, Surface 2) and this page now
|
|
847
|
+
* does the same without being asked:
|
|
848
|
+
*
|
|
849
|
+
* - it pins at `railTop`, so the two columns clear the host's header by the
|
|
850
|
+
* same edge and there is no second number to keep in step;
|
|
851
|
+
* - it is a `@media (pointer: fine)` rule, so a phone keeps its toolbar in
|
|
852
|
+
* flow — a pinned bar over a 390px viewport spends the fold on chrome;
|
|
853
|
+
* - the row reserves its own height whether or not it pins, so nothing in
|
|
854
|
+
* the feed moves when the rule engages.
|
|
855
|
+
*
|
|
856
|
+
* `stickyToolbar` still wins where it is given (it pins on every pointer, at
|
|
857
|
+
* its own offset). `false` turns the default off and leaves the row static —
|
|
858
|
+
* for a surface that draws a bar of its own over the page.
|
|
859
|
+
*/
|
|
860
|
+
readonly toolbarSticky?: boolean;
|
|
825
861
|
/**
|
|
826
862
|
* The host's own exits from an empty result — sibling sections with their
|
|
827
863
|
* counts. A SLOT for the same reason `breadcrumb` is one: walking the tree
|
|
@@ -990,8 +1026,10 @@ interface SearchPageBodyProps {
|
|
|
990
1026
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
991
1027
|
readonly railTop?: number | string;
|
|
992
1028
|
readonly railScrollbar?: SearchRailScrollbar;
|
|
1029
|
+
readonly railSurface?: SearchRailSurface;
|
|
993
1030
|
readonly blockRhythm?: SearchBlockRhythm;
|
|
994
1031
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
1032
|
+
readonly toolbarSticky?: boolean;
|
|
995
1033
|
readonly defaultFiltersOpen?: boolean;
|
|
996
1034
|
readonly filtersOpen?: boolean;
|
|
997
1035
|
readonly onFiltersOpenChange?: (
|
|
@@ -1178,6 +1216,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1178
1216
|
<FacetPanelPane
|
|
1179
1217
|
{...(layout === "sheet" ? { heading: null } : {})}
|
|
1180
1218
|
{...(footerBar !== undefined ? { footerBar } : {})}
|
|
1219
|
+
{...(props.railSurface !== undefined
|
|
1220
|
+
? { railSurface: props.railSurface }
|
|
1221
|
+
: {})}
|
|
1181
1222
|
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "sheet" : "field")}
|
|
1182
1223
|
// `??` would treat an explicit `null` ("never fold") the same as
|
|
1183
1224
|
// "not set": `visibleGroups` uses `null` as a real value, unlike
|
|
@@ -1250,6 +1291,10 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1250
1291
|
</Flex>
|
|
1251
1292
|
);
|
|
1252
1293
|
|
|
1294
|
+
/* ONE OFFSET FOR BOTH COLUMNS. The rail's sticky top edge and the toolbar's
|
|
1295
|
+
are the same edge — the foot of whatever chrome the host pinned above this
|
|
1296
|
+
page — so `railTop` feeds both rather than the page asking for the number
|
|
1297
|
+
twice and letting the two halves disagree. */
|
|
1253
1298
|
const results = (
|
|
1254
1299
|
<SearchResultsPane
|
|
1255
1300
|
toolbar={toolbar}
|
|
@@ -1258,6 +1303,10 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1258
1303
|
{...(props.stickyToolbar !== undefined
|
|
1259
1304
|
? { stickyToolbar: props.stickyToolbar }
|
|
1260
1305
|
: {})}
|
|
1306
|
+
{...(props.toolbarSticky !== undefined
|
|
1307
|
+
? { toolbarSticky: props.toolbarSticky }
|
|
1308
|
+
: {})}
|
|
1309
|
+
{...(props.railTop !== undefined ? { toolbarTop: props.railTop } : {})}
|
|
1261
1310
|
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
1262
1311
|
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
1263
1312
|
{...(props.wrapResults !== undefined ? { wrapResults: props.wrapResults } : {})}
|
|
@@ -1363,7 +1412,25 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1363
1412
|
the written decision, not an oversight: a page with nothing to say
|
|
1364
1413
|
about location says nothing rather than reserving a blank row. */}
|
|
1365
1414
|
{props.resultsHeader !== undefined && (
|
|
1366
|
-
|
|
1415
|
+
/* NO BOX OF ITS OWN (`display: contents`). The slot is a NODE, and a
|
|
1416
|
+
node that renders nothing is indistinguishable from one that renders
|
|
1417
|
+
something until React has run it — so the wrapper was mounted on the
|
|
1418
|
+
prop alone and stood in this column as a 1392 x 0 element whenever
|
|
1419
|
+
the host's header had nothing to say. Inside the block rhythm an
|
|
1420
|
+
empty child is not free: the column's `gap` is charged on BOTH sides
|
|
1421
|
+
of it, so the distance between the two real blocks around it
|
|
1422
|
+
measured 64px where 32 is declared, on every feed page (owner's
|
|
1423
|
+
tidiness probe on the stand).
|
|
1424
|
+
|
|
1425
|
+
`display: contents` generates no box at all: with nothing inside,
|
|
1426
|
+
there is no flex item and no gap; with something inside, the host's
|
|
1427
|
+
own element IS the column's child and takes exactly one gap. The
|
|
1428
|
+
`data-testid` survives either way, and the consumer stylesheet's
|
|
1429
|
+
stand-in (`[data-testid="search-results-header"]:empty{display:none}`)
|
|
1430
|
+
can go. */
|
|
1431
|
+
<div style={CONTENTS_BOX} data-testid="search-results-header">
|
|
1432
|
+
{props.resultsHeader}
|
|
1433
|
+
</div>
|
|
1367
1434
|
)}
|
|
1368
1435
|
|
|
1369
1436
|
{/* What the search is NARROWED to, above the results, each constraint
|
|
@@ -1502,8 +1569,10 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1502
1569
|
filtersLayout,
|
|
1503
1570
|
railTop,
|
|
1504
1571
|
railScrollbar,
|
|
1572
|
+
railSurface,
|
|
1505
1573
|
blockRhythm,
|
|
1506
1574
|
stickyToolbar,
|
|
1575
|
+
toolbarSticky,
|
|
1507
1576
|
defaultFiltersOpen,
|
|
1508
1577
|
filtersOpen,
|
|
1509
1578
|
onFiltersOpenChange,
|
|
@@ -1568,8 +1637,10 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1568
1637
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
1569
1638
|
{...(railTop !== undefined ? { railTop } : {})}
|
|
1570
1639
|
{...(railScrollbar !== undefined ? { railScrollbar } : {})}
|
|
1640
|
+
{...(railSurface !== undefined ? { railSurface } : {})}
|
|
1571
1641
|
{...(blockRhythm !== undefined ? { blockRhythm } : {})}
|
|
1572
1642
|
{...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
|
|
1643
|
+
{...(toolbarSticky !== undefined ? { toolbarSticky } : {})}
|
|
1573
1644
|
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
1574
1645
|
{...(filtersOpen !== undefined ? { filtersOpen } : {})}
|
|
1575
1646
|
{...(onFiltersOpenChange !== undefined ? { onFiltersOpenChange } : {})}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* when the answer actually has another page in some direction.
|
|
26
26
|
*/
|
|
27
27
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
28
|
-
import { Flex, Typography } from "antd";
|
|
28
|
+
import { Flex, Typography, theme as antdTheme } from "antd";
|
|
29
29
|
import { errorCode, useT, useTPlural } from "@stapel/core";
|
|
30
30
|
import {
|
|
31
31
|
EmptyState,
|
|
@@ -139,6 +139,76 @@ export interface SearchToolbarPin {
|
|
|
139
139
|
readonly top?: number | string;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The class the DEFAULT pin's rules are hung on — the media-gated half of
|
|
144
|
+
* {@link SearchResultsPaneProps.toolbarSticky}.
|
|
145
|
+
*
|
|
146
|
+
* A class and a sheet rather than an inline style, for the one reason a sheet
|
|
147
|
+
* is ever right here: the pin is `@media (pointer: fine)` and a media query
|
|
148
|
+
* cannot be said in a `style` attribute. A pinned sort row is a desktop
|
|
149
|
+
* affordance — a phone's toolbar is already one tap from the top of a short
|
|
150
|
+
* scroll, and a bar standing over a 390px viewport spends the fold on chrome
|
|
151
|
+
* the reader did not ask for (REPORT §24, Surface 2: what the reference pins
|
|
152
|
+
* is the DESKTOP feed's sort row).
|
|
153
|
+
*/
|
|
154
|
+
export const RESULTS_TOOLBAR_STICKY_CLASS = "stapel-search-results-toolbar-sticky";
|
|
155
|
+
|
|
156
|
+
/** The custom property the default pin reads its offset from. */
|
|
157
|
+
export const RESULTS_TOOLBAR_TOP_VAR = "--stapel-search-toolbar-top";
|
|
158
|
+
|
|
159
|
+
/** The `href` the hoisted toolbar sheet is deduplicated by. */
|
|
160
|
+
export const RESULTS_TOOLBAR_STYLE_HREF = "stapel-search-toolbar";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The default pin's rule set.
|
|
164
|
+
*
|
|
165
|
+
* Everything {@link toolbarPinStyle} writes inline, said once in a sheet and
|
|
166
|
+
* gated on a fine pointer — the offset arrives per instance through
|
|
167
|
+
* {@link RESULTS_TOOLBAR_TOP_VAR}, which is how one static rule serves a page
|
|
168
|
+
* whose header height only the host knows. `0px` is the fallback, which is
|
|
169
|
+
* where a page with no chrome above it pins.
|
|
170
|
+
*/
|
|
171
|
+
export function toolbarStickyCss(): string {
|
|
172
|
+
const bar = `.${RESULTS_TOOLBAR_STICKY_CLASS}`;
|
|
173
|
+
return (
|
|
174
|
+
`@media (pointer:fine){` +
|
|
175
|
+
`${bar}{position:sticky;top:var(${RESULTS_TOOLBAR_TOP_VAR},0px);` +
|
|
176
|
+
// Over the cards, under the page's own chrome — and under antd's popups,
|
|
177
|
+
// so the sort select still opens over its own bar. Opaque, or the cards
|
|
178
|
+
// scroll THROUGH the row.
|
|
179
|
+
`z-index:1;background:${cssVar("surface")}}}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The toolbar row's own block-size, reserved from the FIRST frame.
|
|
185
|
+
*
|
|
186
|
+
* The same discipline — and the same arithmetic — as `chipRowMinHeight`: a
|
|
187
|
+
* NUMBER OUT OF THE THEME rather than a constant, because the shared
|
|
188
|
+
* `SkinTheme` raises `controlHeight` to the 44px touch floor below the tablet
|
|
189
|
+
* breakpoint and a hard-coded reserve is then right on one surface and wrong
|
|
190
|
+
* on the other.
|
|
191
|
+
*
|
|
192
|
+
* Why a row that is already this tall states it anyway: the row's contents
|
|
193
|
+
* arrive in two frames — the count lands with the answer, and a sort select
|
|
194
|
+
* whose options are still loading measures its placeholder — so the box a
|
|
195
|
+
* pinned bar occupies must not be a consequence of what is inside it. A
|
|
196
|
+
* reserve makes the height a constant before and after the pin engages, which
|
|
197
|
+
* is what "no layout shift when it pins" means in a `position: sticky` world:
|
|
198
|
+
* the sticky box keeps its place in flow, so the only way it can move the feed
|
|
199
|
+
* is by changing its own height.
|
|
200
|
+
*/
|
|
201
|
+
export function toolbarRowMinHeight(controlHeight: number): number {
|
|
202
|
+
return controlHeight + spacing[1] * 2;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A CSS length from a prop that is a number of pixels or a string as written
|
|
206
|
+
* (a `var()`, a `calc()`, `"4rem"`) — the rule `railStyle` follows. */
|
|
207
|
+
function toolbarTopLength(top: number | string | undefined): string {
|
|
208
|
+
if (top === undefined) return "0px";
|
|
209
|
+
return typeof top === "number" ? `${String(top)}px` : top;
|
|
210
|
+
}
|
|
211
|
+
|
|
142
212
|
/**
|
|
143
213
|
* The controls' own line: it may not become two.
|
|
144
214
|
*
|
|
@@ -598,6 +668,36 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
598
668
|
* pins it where it stands.
|
|
599
669
|
*/
|
|
600
670
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
671
|
+
/**
|
|
672
|
+
* PIN the toolbar row by default, on a fine pointer. Default `true`.
|
|
673
|
+
*
|
|
674
|
+
* The same argument as {@link stickyToolbar} and none of the wiring: a
|
|
675
|
+
* catalogue page is thirty cards long, the control that reorders them is at
|
|
676
|
+
* the top of it, and by the fourth row the sort is a screenful above the
|
|
677
|
+
* list it sorts. The reference pins its sort bar once a reader has scrolled
|
|
678
|
+
* into results (REPORT §24, Surface 2); this pair had the mechanism and made
|
|
679
|
+
* every host ask for it, so no deployment had it.
|
|
680
|
+
*
|
|
681
|
+
* What the default does that the explicit prop cannot: it is a `@media
|
|
682
|
+
* (pointer: fine)` rule (see {@link toolbarStickyCss}), so a phone — where
|
|
683
|
+
* a pinned bar costs a fifth of the fold and the feed is a flick long —
|
|
684
|
+
* keeps its toolbar in flow. `stickyToolbar` stays the host's own
|
|
685
|
+
* instruction and is honoured on every pointer; passing it takes this
|
|
686
|
+
* default out of play, so the two can never both be on one row.
|
|
687
|
+
*
|
|
688
|
+
* `false` leaves the row exactly where it stood: no class, no sheet, no
|
|
689
|
+
* custom property.
|
|
690
|
+
*/
|
|
691
|
+
readonly toolbarSticky?: boolean;
|
|
692
|
+
/**
|
|
693
|
+
* WHERE the default pin's top edge is — the results column's half of
|
|
694
|
+
* `<SearchPage railTop>`, and the same value.
|
|
695
|
+
*
|
|
696
|
+
* A number is pixels; a string is taken as written, so
|
|
697
|
+
* `toolbarTop="var(--stapel-header-height)"` reads the height
|
|
698
|
+
* `<PublicShell>` publishes rather than restating it. Default `0`.
|
|
699
|
+
*/
|
|
700
|
+
readonly toolbarTop?: number | string;
|
|
601
701
|
/**
|
|
602
702
|
* The category's feature schema, used ONLY to name an applied filter in the
|
|
603
703
|
* empty state's exits ("Without Brand" rather than "Without vendor").
|
|
@@ -706,6 +806,34 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
706
806
|
// not, and `headingVisible` overrides either way — see that prop.
|
|
707
807
|
const compactHeadingSeen = props.headingVisible ?? props.heading !== undefined;
|
|
708
808
|
const toolbarPin = toolbarPinStyle(props.stickyToolbar);
|
|
809
|
+
// The chips' own height, from the theme they are drawn in — see
|
|
810
|
+
// `toolbarRowMinHeight`.
|
|
811
|
+
const { token } = antdTheme.useToken();
|
|
812
|
+
/* The DEFAULT pin, and the host's explicit one takes precedence: a row
|
|
813
|
+
carrying both would be pinned twice at two offsets, and which one wins
|
|
814
|
+
would be whichever declaration the cascade happened to resolve last. */
|
|
815
|
+
const defaultPin = props.stickyToolbar === undefined && props.toolbarSticky !== false;
|
|
816
|
+
const toolbarClass = defaultPin
|
|
817
|
+
? `${RESULTS_TOOLBAR_CLASS} ${RESULTS_TOOLBAR_STICKY_CLASS}`
|
|
818
|
+
: RESULTS_TOOLBAR_CLASS;
|
|
819
|
+
/* The reserve is written whether or not the row pins, and that is the
|
|
820
|
+
point: a height that only exists while pinned is a height that changes
|
|
821
|
+
when the pin engages. */
|
|
822
|
+
const toolbarBox: CSSProperties = {
|
|
823
|
+
minBlockSize: toolbarRowMinHeight(token.controlHeight),
|
|
824
|
+
...(defaultPin
|
|
825
|
+
? ({ [RESULTS_TOOLBAR_TOP_VAR]: toolbarTopLength(props.toolbarTop) } as CSSProperties)
|
|
826
|
+
: {}),
|
|
827
|
+
...toolbarPin,
|
|
828
|
+
};
|
|
829
|
+
/* Hoisted and deduped by `href` (React 19), and not mounted at all when
|
|
830
|
+
nothing carries the class — a sheet whose only selector is a class no
|
|
831
|
+
node has is dead weight in the document. */
|
|
832
|
+
const toolbarSheet = defaultPin ? (
|
|
833
|
+
<style href={RESULTS_TOOLBAR_STYLE_HREF} precedence="default">
|
|
834
|
+
{toolbarStickyCss()}
|
|
835
|
+
</style>
|
|
836
|
+
) : null;
|
|
709
837
|
const columnRules =
|
|
710
838
|
props.columns === undefined || props.layout === "list"
|
|
711
839
|
? null
|
|
@@ -739,6 +867,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
739
867
|
...(maxWidth !== null ? { maxWidth } : {}),
|
|
740
868
|
}}
|
|
741
869
|
>
|
|
870
|
+
{toolbarSheet}
|
|
742
871
|
<SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
|
|
743
872
|
{(bag) => (
|
|
744
873
|
<Flex
|
|
@@ -773,9 +902,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
773
902
|
{props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
|
|
774
903
|
</Typography.Title>
|
|
775
904
|
<div
|
|
776
|
-
className={
|
|
905
|
+
className={toolbarClass}
|
|
777
906
|
data-testid="search-results-toolbar"
|
|
778
|
-
style={{ ...COMPACT_TOOLBAR, ...
|
|
907
|
+
style={{ ...COMPACT_TOOLBAR, ...toolbarBox }}
|
|
779
908
|
>
|
|
780
909
|
{props.toolbar}
|
|
781
910
|
</div>
|
|
@@ -800,9 +929,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
800
929
|
<Flex
|
|
801
930
|
align="center"
|
|
802
931
|
gap={spacing[3]}
|
|
803
|
-
className={
|
|
932
|
+
className={toolbarClass}
|
|
804
933
|
data-testid="search-results-toolbar"
|
|
805
|
-
style={{ ...TOOLBAR_ROW, ...
|
|
934
|
+
style={{ ...TOOLBAR_ROW, ...toolbarBox }}
|
|
806
935
|
>
|
|
807
936
|
{/* The elastic half, present whether or not there is a
|
|
808
937
|
count in it — see TOOLBAR_LEAD. It is what holds the
|