@stapel/search-react 0.42.0 → 0.42.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 +40 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +31 -7
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/PopularValues.d.ts +9 -0
- package/dist/default/PopularValues.d.ts.map +1 -1
- package/dist/default/PopularValues.js +45 -9
- package/dist/default/PopularValues.js.map +1 -1
- package/dist/default/SearchPage.d.ts +7 -42
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +9 -74
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/focusRing.d.ts +83 -0
- package/dist/default/focusRing.d.ts.map +1 -0
- package/dist/default/focusRing.js +37 -0
- package/dist/default/focusRing.js.map +1 -0
- package/dist/default/index.d.ts +4 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/scrollbar.d.ts +89 -0
- package/dist/default/scrollbar.d.ts.map +1 -0
- package/dist/default/scrollbar.js +69 -0
- package/dist/default/scrollbar.js.map +1 -0
- package/llms.txt +1 -1
- package/manifest.json +1 -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 +58 -1
- package/src/default/PopularValues.tsx +62 -8
- package/src/default/SearchPage.tsx +18 -77
- package/src/default/focusRing.ts +100 -0
- package/src/default/index.ts +16 -0
- package/src/default/scrollbar.ts +119 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.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": "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, 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, then 34 -> 34.5 KB for THE RAIL THAT SCROLLS WITH THE PAGE (`railScroll`, a founder's ruling on a client storefront). Measured with dependencies held constant, this package's src before this change and then at it: 33661 -> 34193 B — 532 B, 193 of them over the old line. The rail has been its own scroll container since it became sticky and stays one by default; `railScroll=\"page\"` is the other answer, for a surface whose owner reads a second scrollbar standing beside the results as a second page: no `overflow-y`, no height cap, no gutter, no `overscroll-behavior`, and no bar to dress a port that is not there. The weight is the half that is NOT a deletion, because `\"page\"` is not \"sticky off\": a rail SHORTER than the room under the host's chrome still pins at `railTop`, and only a rail taller than the window goes static — a stuck box that tall is cut off at the foot of the screen, and the page scroll, now the only scroll on the surface, cannot reach its last controls. Which of the two a leaf gets is MEASURED and not guessed from a breakpoint (`useRailFits`: the rail's own height against `window.innerHeight` minus the offset it carries as `scroll-margin-top`, an observer for the element and a listener for the window, and zero refused as a measurement), because the same catalogue draws four facet groups on one section and twenty on the next. 34.5 KB leaves 307 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, then 34 -> 34.5 KB for THE RAIL THAT SCROLLS WITH THE PAGE (`railScroll`, a founder's ruling on a client storefront). Measured with dependencies held constant, this package's src before this change and then at it: 33661 -> 34193 B — 532 B, 193 of them over the old line. The rail has been its own scroll container since it became sticky and stays one by default; `railScroll=\"page\"` is the other answer, for a surface whose owner reads a second scrollbar standing beside the results as a second page: no `overflow-y`, no height cap, no gutter, no `overscroll-behavior`, and no bar to dress a port that is not there. The weight is the half that is NOT a deletion, because `\"page\"` is not \"sticky off\": a rail SHORTER than the room under the host's chrome still pins at `railTop`, and only a rail taller than the window goes static — a stuck box that tall is cut off at the foot of the screen, and the page scroll, now the only scroll on the surface, cannot reach its last controls. Which of the two a leaf gets is MEASURED and not guessed from a breakpoint (`useRailFits`: the rail's own height against `window.innerHeight` minus the offset it carries as `scroll-margin-top`, an observer for the element and a listener for the window, and zero refused as a measurement), because the same catalogue draws four facet groups on one section and twenty on the next. 34.5 KB leaves 307 B, then 34.5 -> 34.75 KB for THE THREE THINGS THE OWNER READ AS BROKEN ON A CLIENT STOREFRONT'S CARS LEAF. Measured with dependencies held constant, this package's src before this change and then at it: 34193 -> 34490 B — 297 B, and the old line had 10 B of room left, which is a ceiling that would fail CI on rounding rather than on weight. First, the dictionary's VALUES BOX is a scroll port and was drawing the platform's bar over the row's last column — the count sat under a grey strip. The rail's own scrollbar rule set moves to `./scrollbar.ts` and both ports mount the one hoisted sheet: `scrollbar-gutter: stable` plus a 6px token hairline, the row's count carrying the same measure as padding for the engines that ignore the gutter, and the list opening one step of the scale below the box that filters it. Second, `./focusRing.ts`: the facet controls stamp `data-pointer-focus` on a pointer press and clear it on the first key or on blur, and one rule turns the ring off while that stamp is on. `:focus-visible` was already the only thing drawing it — it is a heuristic, and it fires for a click on a control whose own list has just changed under it, which is every disclosure toggle in the rail. Third, `<PopularValues>` sizes its columns to their words (`inline-size: fit-content`, a scale gap, four columns at most) instead of dividing the whole results pane into three, which printed a make, 300px of nothing, and the next make",
|
|
61
61
|
"path": "dist/default/index.js",
|
|
62
|
-
"limit": "34.
|
|
62
|
+
"limit": "34.75 KB"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"name": "router — the react-router binding is opt-in; the main entry must never pull a router",
|
|
@@ -116,11 +116,11 @@
|
|
|
116
116
|
"typescript": "^5.8.3",
|
|
117
117
|
"vitest": "^3.2.4",
|
|
118
118
|
"@stapel/attributes-react": "^0.17.0",
|
|
119
|
-
"@stapel/showcase": "^0.3.0",
|
|
120
119
|
"@stapel/core": "^0.26.1",
|
|
121
120
|
"@stapel/image": "^0.4.2",
|
|
121
|
+
"@stapel/showcase": "^0.3.0",
|
|
122
122
|
"@stapel/tokens": "^0.8.0",
|
|
123
|
-
"@stapel/tokens-antd": "^0.18.
|
|
123
|
+
"@stapel/tokens-antd": "^0.18.1"
|
|
124
124
|
},
|
|
125
125
|
"engines": {
|
|
126
126
|
"node": ">=22"
|
|
@@ -72,6 +72,18 @@ import type { PickerGroup, PickerOption } from "@stapel/tokens-antd/skin";
|
|
|
72
72
|
import { controls, cssVar, radii, spacing } from "@stapel/tokens";
|
|
73
73
|
import { featureConfig, featureType } from "@stapel/attributes-react";
|
|
74
74
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
75
|
+
import {
|
|
76
|
+
POINTER_FOCUS,
|
|
77
|
+
POINTER_FOCUS_STYLE_HREF,
|
|
78
|
+
pointerFocusCss,
|
|
79
|
+
} from "./focusRing.js";
|
|
80
|
+
import {
|
|
81
|
+
RAIL_SCROLLBAR_CLASS,
|
|
82
|
+
RAIL_STYLE_HREF,
|
|
83
|
+
SCROLL_GUTTER_INLINE_END,
|
|
84
|
+
SCROLL_LIST_INSET_BLOCK_START,
|
|
85
|
+
railScrollbarCss,
|
|
86
|
+
} from "./scrollbar.js";
|
|
75
87
|
import { facetGroupIsVocabularyBacked } from "../state/facets.js";
|
|
76
88
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
77
89
|
import { translitPrefixMatch } from "../state/translit.js";
|
|
@@ -331,6 +343,9 @@ function OptionCount(props: {
|
|
|
331
343
|
return (
|
|
332
344
|
<Typography.Text
|
|
333
345
|
type="secondary"
|
|
346
|
+
// The last column of a row that may live inside a scroll port, so it
|
|
347
|
+
// keeps the port's gutter clear of itself — see `SCROLL_GUTTER_INLINE_END`.
|
|
348
|
+
style={SCROLL_GUTTER_INLINE_END}
|
|
334
349
|
data-testid={`facet-count-${props.group.slug}-${props.option.value}`}
|
|
335
350
|
>
|
|
336
351
|
{props.option.count === null
|
|
@@ -436,6 +451,7 @@ function OptionPill(props: {
|
|
|
436
451
|
shape="round"
|
|
437
452
|
type={option.selected ? "primary" : "default"}
|
|
438
453
|
aria-pressed={option.selected}
|
|
454
|
+
{...POINTER_FOCUS}
|
|
439
455
|
style={style}
|
|
440
456
|
data-testid={`facet-option-${group.slug}-${option.value}`}
|
|
441
457
|
data-analytics="none"
|
|
@@ -505,12 +521,28 @@ function ChevronGlyph(props: { readonly open: boolean }): ReactElement {
|
|
|
505
521
|
* The list a dictionary scrolls in. A vocabulary level is 418 makes: without
|
|
506
522
|
* a ceiling the group alone is longer than the rail, and the box that filters
|
|
507
523
|
* it scrolls off the top of the panel while you type into it.
|
|
524
|
+
*
|
|
525
|
+
* It is a scroll port, so it takes the panel's two scroll-port rules — see
|
|
526
|
+
* `./scrollbar.ts`. The bar it used to draw was the PLATFORM's, painted over
|
|
527
|
+
* the row's last column: on the live storefront «Chery 5» read as «Chery»
|
|
528
|
+
* with the 5 under a grey strip. `scrollbar-gutter: stable` (in the sheet)
|
|
529
|
+
* subtracts that space from the content box instead, the row's count carries
|
|
530
|
+
* the same measure as padding for the engines that ignore the gutter, and the
|
|
531
|
+
* thumb itself is the skin's hairline rather than chrome.
|
|
532
|
+
*
|
|
533
|
+
* The top inset is the other half of the same complaint: the first value was
|
|
534
|
+
* drawn flush under the box that filters the list and read as clipped BY it.
|
|
508
535
|
*/
|
|
509
536
|
const DICTIONARY_LIST: CSSProperties = {
|
|
510
537
|
maxBlockSize: 320,
|
|
511
538
|
overflowY: "auto",
|
|
512
539
|
// The scroll must not clip a focus ring against the panel's edge.
|
|
513
540
|
paddingInlineEnd: spacing[1],
|
|
541
|
+
paddingBlockStart: SCROLL_LIST_INSET_BLOCK_START,
|
|
542
|
+
// Stated inline as well as in the sheet: the sheet is what dresses the
|
|
543
|
+
// thumb, and this is the one property that must hold even if a host never
|
|
544
|
+
// mounts it.
|
|
545
|
+
scrollbarGutter: "stable",
|
|
514
546
|
};
|
|
515
547
|
|
|
516
548
|
/**
|
|
@@ -605,7 +637,18 @@ function DictionaryBody(props: {
|
|
|
605
637
|
{t(SEARCH_I18N_KEYS.facetsDictionaryEmpty)}
|
|
606
638
|
</Typography.Text>
|
|
607
639
|
) : (
|
|
608
|
-
<Flex
|
|
640
|
+
<Flex
|
|
641
|
+
vertical
|
|
642
|
+
gap={spacing[1]}
|
|
643
|
+
className={RAIL_SCROLLBAR_CLASS}
|
|
644
|
+
style={DICTIONARY_LIST}
|
|
645
|
+
data-testid={`facet-dictionary-list-${group.slug}`}
|
|
646
|
+
>
|
|
647
|
+
{/* The skin's bar for this port, hoisted and deduped by `href` with
|
|
648
|
+
the rail's own: a pseudo-element has no inline form. */}
|
|
649
|
+
<style href={RAIL_STYLE_HREF} precedence="default">
|
|
650
|
+
{railScrollbarCss()}
|
|
651
|
+
</style>
|
|
609
652
|
{shown.map((option) => (
|
|
610
653
|
<CheckboxRow
|
|
611
654
|
key={option.value}
|
|
@@ -620,6 +663,7 @@ function DictionaryBody(props: {
|
|
|
620
663
|
<Button
|
|
621
664
|
type="link"
|
|
622
665
|
size="small"
|
|
666
|
+
{...POINTER_FOCUS}
|
|
623
667
|
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
624
668
|
data-testid={`facet-more-${group.slug}`}
|
|
625
669
|
data-analytics="none"
|
|
@@ -703,12 +747,18 @@ function DictionaryField(props: {
|
|
|
703
747
|
aria-expanded={open}
|
|
704
748
|
aria-haspopup="listbox"
|
|
705
749
|
aria-label={group.label}
|
|
750
|
+
className={POINTER_FOCUS.className}
|
|
751
|
+
onPointerDown={POINTER_FOCUS.onPointerDown}
|
|
752
|
+
onBlur={POINTER_FOCUS.onBlur}
|
|
706
753
|
style={DICTIONARY_FIELD}
|
|
707
754
|
data-testid={`facet-dictionary-field-${group.slug}`}
|
|
708
755
|
data-chosen={chosen.length}
|
|
709
756
|
data-analytics="none"
|
|
710
757
|
data-analytics-reason="opening a filter group is a read, not a flow step"
|
|
711
758
|
onKeyDown={(event) => {
|
|
759
|
+
// This control owns its own keyboard, so it clears the pointer
|
|
760
|
+
// stamp itself rather than spreading POINTER_FOCUS whole.
|
|
761
|
+
POINTER_FOCUS.onKeyDown(event);
|
|
712
762
|
if (event.key === "Escape" && open) {
|
|
713
763
|
event.preventDefault();
|
|
714
764
|
setOpen(false);
|
|
@@ -1264,10 +1314,16 @@ export function FacetGroupControl(
|
|
|
1264
1314
|
// MARKED, so a storefront's own test can refuse to ship it.
|
|
1265
1315
|
data-label-source={group.labelSource}
|
|
1266
1316
|
>
|
|
1317
|
+
{/* The ring the keyboard gets and the mouse does not — one hoisted
|
|
1318
|
+
element for the whole document, deduped by `href`. */}
|
|
1319
|
+
<style href={POINTER_FOCUS_STYLE_HREF} precedence="default">
|
|
1320
|
+
{pointerFocusCss()}
|
|
1321
|
+
</style>
|
|
1267
1322
|
{props.heading !== false &&
|
|
1268
1323
|
(disclosure ? (
|
|
1269
1324
|
<button
|
|
1270
1325
|
type="button"
|
|
1326
|
+
{...POINTER_FOCUS}
|
|
1271
1327
|
style={DISCLOSURE_HEADER}
|
|
1272
1328
|
aria-expanded={open}
|
|
1273
1329
|
data-testid={`facet-toggle-${group.slug}`}
|
|
@@ -1338,6 +1394,7 @@ export function FacetGroupControl(
|
|
|
1338
1394
|
<Button
|
|
1339
1395
|
type="link"
|
|
1340
1396
|
size="small"
|
|
1397
|
+
{...POINTER_FOCUS}
|
|
1341
1398
|
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
1342
1399
|
data-testid={`facet-more-${group.slug}`}
|
|
1343
1400
|
data-analytics="none"
|
|
@@ -30,6 +30,11 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
|
30
30
|
import { Button, Flex, Typography } from "antd";
|
|
31
31
|
import { useT } from "@stapel/core";
|
|
32
32
|
import { breakpoints, spacing } from "@stapel/tokens";
|
|
33
|
+
import {
|
|
34
|
+
POINTER_FOCUS,
|
|
35
|
+
POINTER_FOCUS_STYLE_HREF,
|
|
36
|
+
pointerFocusCss,
|
|
37
|
+
} from "./focusRing.js";
|
|
33
38
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
34
39
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
35
40
|
|
|
@@ -40,6 +45,16 @@ export const POPULAR_VALUES_LIMIT = 12;
|
|
|
40
45
|
* without turning a make into a two-line wrap. */
|
|
41
46
|
export const POPULAR_VALUES_COLUMNS = 3;
|
|
42
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The ceiling on columns, whatever a host or the ladder asks for.
|
|
50
|
+
*
|
|
51
|
+
* Past four the block stops being a table of contents and becomes a grid of
|
|
52
|
+
* two-word cells: each column is one make and one number, and a fifth column
|
|
53
|
+
* on a 1100px results pane puts 60px of air between «Toyota 4» and the next
|
|
54
|
+
* make. Four is where the density still reads as a list.
|
|
55
|
+
*/
|
|
56
|
+
export const POPULAR_VALUES_MAX_COLUMNS = 4;
|
|
57
|
+
|
|
43
58
|
/**
|
|
44
59
|
* The width one column of this block needs: a make and its count, on one line,
|
|
45
60
|
* at the default type step. «Ford 1 204» is the measure; below it a value
|
|
@@ -117,6 +132,38 @@ export function popularOptions(
|
|
|
117
132
|
.slice(0, limit);
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The columns box: as wide as its WORDS, and no wider.
|
|
137
|
+
*
|
|
138
|
+
* Multi-column layout divides the container, so three columns of a block that
|
|
139
|
+
* is handed the whole results pane are three ~360px columns holding «Chery 5»
|
|
140
|
+
* — which on the storefront printed a make, then 300px of nothing, then the
|
|
141
|
+
* next make. The owner read it as broken, and it is: nothing about this block
|
|
142
|
+
* wants the pane's width, it wants its own.
|
|
143
|
+
*
|
|
144
|
+
* `inline-size: fit-content` is the whole fix. A multi-column box's max-content
|
|
145
|
+
* size is `columns × (the widest item) + gaps` — exactly the block's natural
|
|
146
|
+
* measure — and `fit-content` takes that unless the available space is
|
|
147
|
+
* smaller, in which case the columns shrink instead of overflowing. The box
|
|
148
|
+
* stays a block, so it stays flush with the pane's leading edge; the ladder in
|
|
149
|
+
* the sheet still decides HOW MANY columns, and each one is now sized by the
|
|
150
|
+
* longest make in it.
|
|
151
|
+
*
|
|
152
|
+
* The gap is one step up the scale from the row's own: at content width the
|
|
153
|
+
* columns sit close enough that the old 16px read as a wrapped line rather
|
|
154
|
+
* than as a new column.
|
|
155
|
+
*/
|
|
156
|
+
function COLUMNS(count: number | undefined): CSSProperties {
|
|
157
|
+
return {
|
|
158
|
+
// The rungs live in the sheet; an inline `column-count` would win against
|
|
159
|
+
// every one of them, so the responsive arm states none.
|
|
160
|
+
...(count === undefined ? {} : { columnCount: count }),
|
|
161
|
+
columnGap: spacing[6],
|
|
162
|
+
inlineSize: "fit-content",
|
|
163
|
+
maxInlineSize: "100%",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
120
167
|
const ROW: CSSProperties = {
|
|
121
168
|
// `break-inside` keeps a value and its count on one line when the browser
|
|
122
169
|
// decides where the column ends.
|
|
@@ -171,6 +218,10 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
|
171
218
|
data-label-source={group.labelSource}
|
|
172
219
|
data-columns={responsive ? "responsive" : String(props.columns ?? POPULAR_VALUES_COLUMNS)}
|
|
173
220
|
>
|
|
221
|
+
{/* The ring the keyboard gets and the mouse does not. */}
|
|
222
|
+
<style href={POINTER_FOCUS_STYLE_HREF} precedence="default">
|
|
223
|
+
{pointerFocusCss()}
|
|
224
|
+
</style>
|
|
174
225
|
{/* The ladder, hoisted and deduped by `href`: a container query has no
|
|
175
226
|
inline form. Only where it is asked for — a block with a fixed
|
|
176
227
|
column count needs no sheet at all. */}
|
|
@@ -184,20 +235,22 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
|
184
235
|
)}
|
|
185
236
|
<div
|
|
186
237
|
{...(responsive ? { "data-popular-columns": "" } : {})}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
238
|
+
data-testid={`popular-columns-${group.slug}`}
|
|
239
|
+
style={COLUMNS(
|
|
240
|
+
responsive
|
|
241
|
+
? undefined
|
|
242
|
+
: Math.min(
|
|
243
|
+
props.columns ?? POPULAR_VALUES_COLUMNS,
|
|
244
|
+
POPULAR_VALUES_MAX_COLUMNS
|
|
245
|
+
)
|
|
246
|
+
)}
|
|
195
247
|
>
|
|
196
248
|
{options.map((option) => (
|
|
197
249
|
<div key={option.value} style={ROW}>
|
|
198
250
|
<Button
|
|
199
251
|
type="link"
|
|
200
252
|
size="small"
|
|
253
|
+
{...POINTER_FOCUS}
|
|
201
254
|
style={{ paddingInline: 0, height: "auto" }}
|
|
202
255
|
data-testid={`popular-value-${group.slug}-${option.value}`}
|
|
203
256
|
data-analytics="none"
|
|
@@ -221,6 +274,7 @@ export function PopularValues(props: PopularValuesProps): ReactElement | null {
|
|
|
221
274
|
<Button
|
|
222
275
|
type="link"
|
|
223
276
|
size="small"
|
|
277
|
+
{...POINTER_FOCUS}
|
|
224
278
|
style={{ alignSelf: "flex-start", paddingInline: 0 }}
|
|
225
279
|
data-testid={`popular-all-${group.slug}`}
|
|
226
280
|
data-analytics="none"
|
|
@@ -66,7 +66,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
|
66
66
|
import { Button, Flex } from "antd";
|
|
67
67
|
import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
68
68
|
import { useT, useTPlural } from "@stapel/core";
|
|
69
|
-
import { breakpoints,
|
|
69
|
+
import { breakpoints, spacing } from "@stapel/tokens";
|
|
70
70
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
71
71
|
import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
|
|
72
72
|
import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
|
|
@@ -78,6 +78,11 @@ import type { SearchGeo } from "../api/types.js";
|
|
|
78
78
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
79
79
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
80
80
|
import { useRailFits } from "./railFit.js";
|
|
81
|
+
import {
|
|
82
|
+
RAIL_SCROLLBAR_CLASS,
|
|
83
|
+
RAIL_STYLE_HREF,
|
|
84
|
+
railScrollbarCss,
|
|
85
|
+
} from "./scrollbar.js";
|
|
81
86
|
import { FacetPanelPane } from "./FacetPanelPane.js";
|
|
82
87
|
import type {
|
|
83
88
|
CategoryFilterSlotProps,
|
|
@@ -267,14 +272,19 @@ export const FILTERS_RAIL_WIDTH = 280;
|
|
|
267
272
|
export const RAIL_CLASS = "stapel-search-rail";
|
|
268
273
|
|
|
269
274
|
/**
|
|
270
|
-
* The
|
|
271
|
-
*
|
|
272
|
-
*
|
|
275
|
+
* The skin's scrollbar, which is no longer only the RAIL's — the dictionary
|
|
276
|
+
* facet inside the panel is a scroll port too, and it kept the platform's bar
|
|
277
|
+
* painted over its count column. The rule set, the class, the `href` and the
|
|
278
|
+
* track's width now live in `./scrollbar.ts` so both ports mount one hoisted
|
|
279
|
+
* sheet; they are re-exported here unchanged because they are this package's
|
|
280
|
+
* published surface.
|
|
273
281
|
*/
|
|
274
|
-
export
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
export {
|
|
283
|
+
RAIL_SCROLLBAR_CLASS,
|
|
284
|
+
RAIL_SCROLLBAR_WIDTH,
|
|
285
|
+
RAIL_STYLE_HREF,
|
|
286
|
+
railScrollbarCss,
|
|
287
|
+
} from "./scrollbar.js";
|
|
278
288
|
|
|
279
289
|
/** Whose scrollbar the rail's own scroll port draws — see
|
|
280
290
|
* {@link SearchPageProps.railScrollbar}. */
|
|
@@ -284,75 +294,6 @@ export type SearchRailScrollbar = "styled" | "system";
|
|
|
284
294
|
* {@link SearchPageProps.railScroll}. */
|
|
285
295
|
export type SearchRailScroll = "internal" | "page";
|
|
286
296
|
|
|
287
|
-
/**
|
|
288
|
-
* The scrollbar's track width, in CSS pixels.
|
|
289
|
-
*
|
|
290
|
-
* Not on the spacing scale on purpose, and not a spacing decision: this is the
|
|
291
|
-
* thickness of a hairline instrument, the size every platform's own overlay
|
|
292
|
-
* bar lands within, and the number the storefront's owner named. Six is thin
|
|
293
|
-
* enough to read as part of the panel and thick enough to grab.
|
|
294
|
-
*/
|
|
295
|
-
const RAIL_SCROLLBAR_WIDTH = 6;
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* The rail scrolls, and the bar that says so is the SKIN's, not the platform's.
|
|
299
|
-
*
|
|
300
|
-
* The system bar was never a decision — it is what an `overflow-y: auto` box
|
|
301
|
-
* gets when nobody says otherwise, and on the storefront it landed as a grey
|
|
302
|
-
* chrome-coloured strip standing next to the filters in a dark theme. What it
|
|
303
|
-
* is replaced with:
|
|
304
|
-
*
|
|
305
|
-
* - a 6px track with no arrows and no track fill — the rail's own hairline,
|
|
306
|
-
* not a widget;
|
|
307
|
-
* - a thumb that is TRANSPARENT at rest and appears on `:hover` of the rail
|
|
308
|
-
* (which is what a pointer scrolling inside it is doing) and on
|
|
309
|
-
* `:focus-within` (which is what a keyboard is doing). A coarse pointer
|
|
310
|
-
* fires neither, so under `(pointer: coarse)` the thumb stands — a touch
|
|
311
|
-
* surface with an invisible scrollbar is a rail with no sign it has a tail;
|
|
312
|
-
* - `scrollbar-gutter: stable`, so the panel's right edge does not move when
|
|
313
|
-
* the thumb arrives.
|
|
314
|
-
*
|
|
315
|
-
* Both vendor forms, because they are not alternatives: Firefox reads
|
|
316
|
-
* `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
|
|
317
|
-
* read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
|
|
318
|
-
* standard properties too.
|
|
319
|
-
*
|
|
320
|
-
* The colours are `--stapel-*` custom properties, which resolve per theme at
|
|
321
|
-
* paint time — an inline colour or a `useToken()` value would freeze whichever
|
|
322
|
-
* theme was mounted first. This design system's neutral vocabulary has no
|
|
323
|
-
* `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
|
|
324
|
-
* hairline every pane is separated by) and `text-subtle` is that role one step
|
|
325
|
-
* stronger, which is what the thumb takes when a pointer is on the thumb
|
|
326
|
-
* itself.
|
|
327
|
-
*
|
|
328
|
-
* Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
|
|
329
|
-
* pseudo-element is unreachable from an inline style — the same reason
|
|
330
|
-
* `<LocationSummaryLine>` hoists one.
|
|
331
|
-
*/
|
|
332
|
-
export function railScrollbarCss(): string {
|
|
333
|
-
const bar = `.${RAIL_SCROLLBAR_CLASS}`;
|
|
334
|
-
const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
|
|
335
|
-
const thumb = cssVar("border");
|
|
336
|
-
const awake = `${bar}:hover,${bar}:focus-within`;
|
|
337
|
-
return [
|
|
338
|
-
// ── Firefox ────────────────────────────────────────────────────────────
|
|
339
|
-
`${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
|
|
340
|
-
`scrollbar-color:transparent transparent}`,
|
|
341
|
-
`${awake}{scrollbar-color:${thumb} transparent}`,
|
|
342
|
-
// ── WebKit / Chromium ──────────────────────────────────────────────────
|
|
343
|
-
`${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
|
|
344
|
-
`${bar}::-webkit-scrollbar-track{background:transparent}`,
|
|
345
|
-
`${bar}::-webkit-scrollbar-thumb{background:transparent;` +
|
|
346
|
-
`border-radius:${cssVar("radius-full")}}`,
|
|
347
|
-
`${bar}:hover::-webkit-scrollbar-thumb,` +
|
|
348
|
-
`${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
|
|
349
|
-
`${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
|
|
350
|
-
// ── A surface with no hover at all ─────────────────────────────────────
|
|
351
|
-
`@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
|
|
352
|
-
`${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
|
|
353
|
-
].join("\n");
|
|
354
|
-
}
|
|
355
|
-
|
|
356
297
|
const RAIL: CSSProperties = {
|
|
357
298
|
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
358
299
|
// Both bounds, not just the upper one. `flex-shrink: 0` already holds the
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A focus ring the KEYBOARD gets and the mouse does not.
|
|
3
|
+
*
|
|
4
|
+
* On a live storefront, clicking the "collapse" control under a facet group
|
|
5
|
+
* left a red outline standing around it until something else took focus. The
|
|
6
|
+
* ring itself is right — it is the design system's `:focus-visible` outline,
|
|
7
|
+
* and a keyboard must have it — but it was being drawn for a POINTER, which is
|
|
8
|
+
* the one modality that already knows where it clicked.
|
|
9
|
+
*
|
|
10
|
+
* ── Why `:focus-visible` alone is not the fix ─────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* The usual answer, "draw the ring on `:focus-visible` and not on `:focus`",
|
|
13
|
+
* was already true here: antd draws its button ring from
|
|
14
|
+
* `&:not(:disabled):focus-visible` and nothing in this pair or the storefront's
|
|
15
|
+
* sheet draws one on bare `:focus`. `:focus-visible` is a HEURISTIC, though,
|
|
16
|
+
* not a statement about the last input device: the engine may match it for a
|
|
17
|
+
* click when focus lands on an element whose surroundings have just changed
|
|
18
|
+
* under it — which is exactly what a disclosure toggle does to its own list.
|
|
19
|
+
* Suppressing the ring on `:focus:not(:focus-visible)` therefore fixes nothing
|
|
20
|
+
* on the very control that showed the defect.
|
|
21
|
+
*
|
|
22
|
+
* ── What this states instead ──────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The modality, as a fact the control records for itself: a pointer press
|
|
25
|
+
* stamps `data-pointer-focus` on the element, and the first key it sees — or
|
|
26
|
+
* losing focus — clears it. The sheet then turns the ring OFF while that stamp
|
|
27
|
+
* is on, and leaves every other case to the design system. Tab to the control
|
|
28
|
+
* and the ring is there; click it and it is not; click it and then Tab back to
|
|
29
|
+
* it and it is there again, because the stamp died with the blur.
|
|
30
|
+
*
|
|
31
|
+
* The attribute is written straight to the node rather than held in state, on
|
|
32
|
+
* purpose: it is presentation the frame does not depend on, one control at a
|
|
33
|
+
* time, and a `useState` per facet button would re-render a rail of forty
|
|
34
|
+
* groups to paint an outline. React does not own this attribute, so nothing
|
|
35
|
+
* it renders fights over it.
|
|
36
|
+
*
|
|
37
|
+
* The class is doubled in the selector (`.c.c[…]`) for exactly one reason:
|
|
38
|
+
* specificity. antd's own ring is `.ant-btn:not(:disabled):focus-visible`
|
|
39
|
+
* (0,3,0); a single class plus the attribute plus the pseudo-class ties it, and
|
|
40
|
+
* a tie is settled by whichever stylesheet the engine saw last — which for a
|
|
41
|
+
* runtime CSS-in-JS design system is not a thing this pair gets to decide.
|
|
42
|
+
*/
|
|
43
|
+
import type { CSSProperties } from "react";
|
|
44
|
+
|
|
45
|
+
/** The class the pointer-focus rule is hung on. */
|
|
46
|
+
export const POINTER_FOCUS_CLASS = "stapel-search-pointer-focus";
|
|
47
|
+
|
|
48
|
+
/** The attribute a pointer press stamps, and a key or a blur clears. */
|
|
49
|
+
export const POINTER_FOCUS_ATTR = "data-pointer-focus";
|
|
50
|
+
|
|
51
|
+
/** The `href` the hoisted focus sheet is deduplicated by (React 19). */
|
|
52
|
+
export const POINTER_FOCUS_STYLE_HREF = "stapel-search-pointer-focus";
|
|
53
|
+
|
|
54
|
+
/** See the module note. */
|
|
55
|
+
export function pointerFocusCss(): string {
|
|
56
|
+
const c = `.${POINTER_FOCUS_CLASS}`;
|
|
57
|
+
return `${c}${c}[${POINTER_FOCUS_ATTR}]:focus-visible{outline:none;box-shadow:none}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The handlers a control spreads to opt into the rule above.
|
|
62
|
+
*
|
|
63
|
+
* Typed against the DOM rather than against React's synthetic event so the
|
|
64
|
+
* same object fits a native `<button>` and antd's `<Button>` — both forward
|
|
65
|
+
* these three props to the element, which is the only thing this needs.
|
|
66
|
+
*/
|
|
67
|
+
export interface PointerFocusProps {
|
|
68
|
+
readonly className: string;
|
|
69
|
+
readonly onPointerDown: (event: { currentTarget: Element }) => void;
|
|
70
|
+
readonly onKeyDown: (event: { currentTarget: Element }) => void;
|
|
71
|
+
readonly onBlur: (event: { currentTarget: Element }) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Spread onto any control whose ring should be the keyboard's alone.
|
|
76
|
+
*
|
|
77
|
+
* A constant, not a hook: it closes over nothing, so one object serves every
|
|
78
|
+
* control in the tree and a facet group of forty rows allocates none.
|
|
79
|
+
*/
|
|
80
|
+
export const POINTER_FOCUS: PointerFocusProps = {
|
|
81
|
+
className: POINTER_FOCUS_CLASS,
|
|
82
|
+
onPointerDown: (event) => {
|
|
83
|
+
event.currentTarget.setAttribute(POINTER_FOCUS_ATTR, "");
|
|
84
|
+
},
|
|
85
|
+
onKeyDown: (event) => {
|
|
86
|
+
event.currentTarget.removeAttribute(POINTER_FOCUS_ATTR);
|
|
87
|
+
},
|
|
88
|
+
onBlur: (event) => {
|
|
89
|
+
event.currentTarget.removeAttribute(POINTER_FOCUS_ATTR);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** {@link POINTER_FOCUS} with a className of the caller's own merged in. */
|
|
94
|
+
export function pointerFocus(className: string): PointerFocusProps {
|
|
95
|
+
return { ...POINTER_FOCUS, className: `${className} ${POINTER_FOCUS_CLASS}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Nothing this module styles inline — exported so a host composing its own
|
|
99
|
+
* control can state the same intent without re-deriving the selector. */
|
|
100
|
+
export const POINTER_FOCUS_NO_RING: CSSProperties = { outline: "none" };
|
package/src/default/index.ts
CHANGED
|
@@ -54,6 +54,21 @@ export {
|
|
|
54
54
|
railStyle,
|
|
55
55
|
} from "./SearchPage.js";
|
|
56
56
|
export { RAIL_OFFSET_PROPERTY, useRailFits } from "./railFit.js";
|
|
57
|
+
export {
|
|
58
|
+
RAIL_SCROLLBAR_WIDTH,
|
|
59
|
+
SCROLL_GUTTER_INLINE_END,
|
|
60
|
+
SCROLL_LIST_INSET_BLOCK_START,
|
|
61
|
+
} from "./scrollbar.js";
|
|
62
|
+
export {
|
|
63
|
+
POINTER_FOCUS,
|
|
64
|
+
POINTER_FOCUS_ATTR,
|
|
65
|
+
POINTER_FOCUS_CLASS,
|
|
66
|
+
POINTER_FOCUS_NO_RING,
|
|
67
|
+
POINTER_FOCUS_STYLE_HREF,
|
|
68
|
+
pointerFocus,
|
|
69
|
+
pointerFocusCss,
|
|
70
|
+
} from "./focusRing.js";
|
|
71
|
+
export type { PointerFocusProps } from "./focusRing.js";
|
|
57
72
|
export type {
|
|
58
73
|
SearchPageProps,
|
|
59
74
|
SearchFiltersLayout,
|
|
@@ -178,6 +193,7 @@ export {
|
|
|
178
193
|
POPULAR_VALUES_COLUMNS,
|
|
179
194
|
POPULAR_VALUES_LADDER,
|
|
180
195
|
POPULAR_VALUES_LIMIT,
|
|
196
|
+
POPULAR_VALUES_MAX_COLUMNS,
|
|
181
197
|
POPULAR_VALUES_STYLE_HREF,
|
|
182
198
|
POPULAR_VALUE_COLUMN_WIDTH,
|
|
183
199
|
} from "./PopularValues.js";
|