@stapel/search-react 0.36.1 → 0.38.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 +58 -0
- package/dist/default/FilterChips.d.ts +10 -3
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +17 -5
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts +108 -15
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +134 -39
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +19 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +4 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +13 -2
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +63 -9
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/index.d.ts +4 -4
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -3
- package/dist/default/index.js.map +1 -1
- package/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/FilterChips.tsx +17 -5
- package/src/default/SearchPage.tsx +228 -40
- package/src/default/SearchResultsPane.tsx +30 -1
- package/src/default/SortSelect.tsx +91 -20
- package/src/default/index.ts +15 -2
|
@@ -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 { cssVar, spacing } from "@stapel/tokens";
|
|
69
|
+
import { breakpoints, cssVar, 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";
|
|
@@ -261,47 +261,89 @@ export const FILTERS_RAIL_WIDTH = 280;
|
|
|
261
261
|
* `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
|
|
262
262
|
* height by default, and a stretched box has nothing to stick to.
|
|
263
263
|
*/
|
|
264
|
-
/** The class the rail's own scrollbar rules are hung on. */
|
|
264
|
+
/** The class the rail's own geometry and scrollbar rules are hung on. */
|
|
265
265
|
export const RAIL_CLASS = "stapel-search-rail";
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The class that carries the SKIN's scrollbar — present under
|
|
269
|
+
* `railScrollbar: "styled"` and absent under `"system"`, so the two arms are
|
|
270
|
+
* one class apart and a stand can read which one is on screen.
|
|
271
|
+
*/
|
|
272
|
+
export const RAIL_SCROLLBAR_CLASS = "stapel-search-rail-scrollbar";
|
|
273
|
+
|
|
267
274
|
/** The `href` the hoisted rail sheet is deduplicated by. */
|
|
268
275
|
export const RAIL_STYLE_HREF = "stapel-search-rail";
|
|
269
276
|
|
|
277
|
+
/** Whose scrollbar the rail's own scroll port draws — see
|
|
278
|
+
* {@link SearchPageProps.railScrollbar}. */
|
|
279
|
+
export type SearchRailScrollbar = "styled" | "system";
|
|
280
|
+
|
|
270
281
|
/**
|
|
271
|
-
* The
|
|
282
|
+
* The scrollbar's track width, in CSS pixels.
|
|
272
283
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
|
|
284
|
+
* Not on the spacing scale on purpose, and not a spacing decision: this is the
|
|
285
|
+
* thickness of a hairline instrument, the size every platform's own overlay
|
|
286
|
+
* bar lands within, and the number the storefront's owner named. Six is thin
|
|
287
|
+
* enough to read as part of the panel and thick enough to grab.
|
|
288
|
+
*/
|
|
289
|
+
const RAIL_SCROLLBAR_WIDTH = 6;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The rail scrolls, and the bar that says so is the SKIN's, not the platform's.
|
|
293
|
+
*
|
|
294
|
+
* The system bar was never a decision — it is what an `overflow-y: auto` box
|
|
295
|
+
* gets when nobody says otherwise, and on the storefront it landed as a grey
|
|
296
|
+
* chrome-coloured strip standing next to the filters in a dark theme. What it
|
|
297
|
+
* is replaced with:
|
|
298
|
+
*
|
|
299
|
+
* - a 6px track with no arrows and no track fill — the rail's own hairline,
|
|
300
|
+
* not a widget;
|
|
301
|
+
* - a thumb that is TRANSPARENT at rest and appears on `:hover` of the rail
|
|
302
|
+
* (which is what a pointer scrolling inside it is doing) and on
|
|
303
|
+
* `:focus-within` (which is what a keyboard is doing). A coarse pointer
|
|
304
|
+
* fires neither, so under `(pointer: coarse)` the thumb stands — a touch
|
|
305
|
+
* surface with an invisible scrollbar is a rail with no sign it has a tail;
|
|
306
|
+
* - `scrollbar-gutter: stable`, so the panel's right edge does not move when
|
|
307
|
+
* the thumb arrives.
|
|
278
308
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* theme was mounted first.
|
|
309
|
+
* Both vendor forms, because they are not alternatives: Firefox reads
|
|
310
|
+
* `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
|
|
311
|
+
* read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
|
|
312
|
+
* standard properties too.
|
|
313
|
+
*
|
|
314
|
+
* The colours are `--stapel-*` custom properties, which resolve per theme at
|
|
315
|
+
* paint time — an inline colour or a `useToken()` value would freeze whichever
|
|
316
|
+
* theme was mounted first. This design system's neutral vocabulary has no
|
|
317
|
+
* `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
|
|
318
|
+
* hairline every pane is separated by) and `text-subtle` is that role one step
|
|
319
|
+
* stronger, which is what the thumb takes when a pointer is on the thumb
|
|
320
|
+
* itself.
|
|
287
321
|
*
|
|
288
322
|
* Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
|
|
289
323
|
* pseudo-element is unreachable from an inline style — the same reason
|
|
290
324
|
* `<LocationSummaryLine>` hoists one.
|
|
291
325
|
*/
|
|
292
326
|
export function railScrollbarCss(): string {
|
|
293
|
-
const
|
|
327
|
+
const bar = `.${RAIL_SCROLLBAR_CLASS}`;
|
|
328
|
+
const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
|
|
329
|
+
const thumb = cssVar("border");
|
|
330
|
+
const awake = `${bar}:hover,${bar}:focus-within`;
|
|
294
331
|
return [
|
|
295
|
-
//
|
|
296
|
-
`${
|
|
297
|
-
|
|
298
|
-
`${
|
|
332
|
+
// ── Firefox ────────────────────────────────────────────────────────────
|
|
333
|
+
`${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
|
|
334
|
+
`scrollbar-color:transparent transparent}`,
|
|
335
|
+
`${awake}{scrollbar-color:${thumb} transparent}`,
|
|
336
|
+
// ── WebKit / Chromium ──────────────────────────────────────────────────
|
|
337
|
+
`${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
|
|
338
|
+
`${bar}::-webkit-scrollbar-track{background:transparent}`,
|
|
339
|
+
`${bar}::-webkit-scrollbar-thumb{background:transparent;` +
|
|
299
340
|
`border-radius:${cssVar("radius-full")}}`,
|
|
300
|
-
`${
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
341
|
+
`${bar}:hover::-webkit-scrollbar-thumb,` +
|
|
342
|
+
`${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
|
|
343
|
+
`${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
|
|
344
|
+
// ── A surface with no hover at all ─────────────────────────────────────
|
|
345
|
+
`@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
|
|
346
|
+
`${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
|
|
305
347
|
].join("\n");
|
|
306
348
|
}
|
|
307
349
|
|
|
@@ -320,14 +362,11 @@ const RAIL: CSSProperties = {
|
|
|
320
362
|
maxHeight: "100dvh",
|
|
321
363
|
overflowY: "auto",
|
|
322
364
|
overscrollBehavior: "contain",
|
|
323
|
-
// The
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
// A thin, always-there scrollbar is the sign there is more; the stable
|
|
329
|
-
// gutter keeps the panel's right edge from jumping when it appears.
|
|
330
|
-
scrollbarWidth: "thin",
|
|
365
|
+
// The gutter is the rail's, whichever bar draws in it: reserved here so the
|
|
366
|
+
// panel's right edge does not move when the thumb arrives. The bar's own
|
|
367
|
+
// width and colour are the skin's and live in `railScrollbarCss` — a rule
|
|
368
|
+
// set, not an inline pair, because a thumb that appears on hover cannot be
|
|
369
|
+
// said in a style attribute.
|
|
331
370
|
scrollbarGutter: "stable",
|
|
332
371
|
// Room for the focus ring of the last control against the scroll edge.
|
|
333
372
|
paddingBlockEnd: spacing[2],
|
|
@@ -355,6 +394,71 @@ export function railStyle(top: number | string | undefined): CSSProperties {
|
|
|
355
394
|
* cannot push the grid wider than its column. */
|
|
356
395
|
const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
357
396
|
|
|
397
|
+
/* ── THE RHYTHM: ONE GAP BETWEEN BLOCKS, SAID ONCE ─────────────────────────
|
|
398
|
+
*
|
|
399
|
+
* This page is an assembly of BLOCKS — the query box, the breadcrumb, the
|
|
400
|
+
* location row, the header band, the applied chips, the columns — and every
|
|
401
|
+
* gap between two of them used to be `spacing[4]` on the root plus whatever
|
|
402
|
+
* outer margin the block itself happened to carry. Sixteen pixels is what a
|
|
403
|
+
* form's fields are spaced by, not what a page's sections are: on the walked
|
|
404
|
+
* storefront the blocks read as one undifferentiated column, and the owner's
|
|
405
|
+
* word for it was that everything is stuck together.
|
|
406
|
+
*
|
|
407
|
+
* The gap is now ONE PAIR of custom properties, declared as a usage and not as
|
|
408
|
+
* a definition — `var(--stapel-block-gap, 32px)`. That is the whole point of
|
|
409
|
+
* the shape: a host (or a container's own stylesheet, or a brand) sets the
|
|
410
|
+
* property anywhere above this page and every block on it moves together,
|
|
411
|
+
* while a host that sets nothing gets the design system's own spacing step.
|
|
412
|
+
*
|
|
413
|
+
* The compact value is for a COARSE POINTER or a narrow window, in one query
|
|
414
|
+
* with two arms: a phone has less height to spend on air, and a tablet held in
|
|
415
|
+
* a hand is a phone for this purpose whatever its width says.
|
|
416
|
+
*
|
|
417
|
+
* The names are the pair's published contract, which is why they are exported:
|
|
418
|
+
* `categories-react`'s pages declare the same two, so a storefront that tunes
|
|
419
|
+
* the rhythm tunes BOTH pairs with one declaration.
|
|
420
|
+
*/
|
|
421
|
+
|
|
422
|
+
/** The custom property every block gap on this page reads. */
|
|
423
|
+
export const BLOCK_GAP_VAR = "--stapel-block-gap";
|
|
424
|
+
|
|
425
|
+
/** Its coarse-pointer / narrow-window twin. */
|
|
426
|
+
export const BLOCK_GAP_COMPACT_VAR = "--stapel-block-gap-compact";
|
|
427
|
+
|
|
428
|
+
/** The class the rhythm's rules are hung on. */
|
|
429
|
+
export const BLOCK_RHYTHM_CLASS = "stapel-block-rhythm";
|
|
430
|
+
|
|
431
|
+
/** The `href` the hoisted rhythm sheet is deduplicated by. */
|
|
432
|
+
export const BLOCK_RHYTHM_STYLE_HREF = "stapel-block-rhythm";
|
|
433
|
+
|
|
434
|
+
/** Where the page's block gap comes from — see
|
|
435
|
+
* {@link SearchPageProps.blockRhythm}. */
|
|
436
|
+
export type SearchBlockRhythm = "token" | "legacy";
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The rhythm's rule set.
|
|
440
|
+
*
|
|
441
|
+
* Three rules, and the third is half of the fix: `margin-block: 0` on every
|
|
442
|
+
* direct child. A gap only governs the space a container puts BETWEEN its
|
|
443
|
+
* children — a block that also carries its own top or bottom margin adds to it
|
|
444
|
+
* and the spacing stops being one number, which is exactly how a page ends up
|
|
445
|
+
* with four different distances nobody chose.
|
|
446
|
+
*
|
|
447
|
+
* A sheet rather than inline styles because the compact arm is a media query
|
|
448
|
+
* and the reset addresses children this component does not own.
|
|
449
|
+
*/
|
|
450
|
+
export function blockRhythmCss(): string {
|
|
451
|
+
const block = `.${BLOCK_RHYTHM_CLASS}`;
|
|
452
|
+
const narrow = `(max-width:${String(breakpoints.tablet - 1)}px)`;
|
|
453
|
+
return [
|
|
454
|
+
`${block}{gap:var(${BLOCK_GAP_VAR},${String(spacing[6])}px)}`,
|
|
455
|
+
`@media (pointer:coarse),${narrow}{` +
|
|
456
|
+
`${block}{gap:var(${BLOCK_GAP_COMPACT_VAR},${String(spacing[5])}px)}}`,
|
|
457
|
+
// A block's own outer margin is a second opinion about the same distance.
|
|
458
|
+
`${block}>*{margin-block:0}`,
|
|
459
|
+
].join("\n");
|
|
460
|
+
}
|
|
461
|
+
|
|
358
462
|
export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions {
|
|
359
463
|
/** The URL binding. `useRouterSearchParams()` from `./router` is the
|
|
360
464
|
* react-router one. */
|
|
@@ -659,6 +763,46 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
659
763
|
* header to clear.
|
|
660
764
|
*/
|
|
661
765
|
readonly railTop?: number | string;
|
|
766
|
+
/**
|
|
767
|
+
* WHOSE SCROLLBAR the rail's own scroll port draws. Default `"styled"`.
|
|
768
|
+
*
|
|
769
|
+
* The rail is a scroll container and stays one: a person who has scrolled
|
|
770
|
+
* the filters and ticked one does not want the page to have moved under
|
|
771
|
+
* them. What it stopped drawing is the PLATFORM's bar — a grey chrome strip
|
|
772
|
+
* beside the filters, which is what an `overflow-y: auto` box gets when
|
|
773
|
+
* nobody decides otherwise.
|
|
774
|
+
*
|
|
775
|
+
* - `"styled"` — the skin's own bar, from the tokens, in both themes: a 6px
|
|
776
|
+
* track, no arrows, no track fill, and a thumb that is transparent at
|
|
777
|
+
* rest and appears while the rail is hovered or focused within (always,
|
|
778
|
+
* on a coarse pointer that can do neither). See {@link railScrollbarCss};
|
|
779
|
+
* - `"system"` — the platform's, untouched, for a host whose own stylesheet
|
|
780
|
+
* already dresses every scroll port on the page and would then be
|
|
781
|
+
* dressing this one twice.
|
|
782
|
+
*
|
|
783
|
+
* The default is the NEW behaviour, deliberately: the system bar was never a
|
|
784
|
+
* design decision here — it was the absence of one, and it is the thing the
|
|
785
|
+
* page was measured on.
|
|
786
|
+
*/
|
|
787
|
+
readonly railScrollbar?: SearchRailScrollbar;
|
|
788
|
+
/**
|
|
789
|
+
* WHERE the space between this page's blocks comes from. Default `"token"`.
|
|
790
|
+
*
|
|
791
|
+
* - `"token"` — one gap for every block, read from
|
|
792
|
+
* `var(--stapel-block-gap)` (and `var(--stapel-block-gap-compact)` on a
|
|
793
|
+
* coarse pointer or a narrow window), defaulting to the design system's
|
|
794
|
+
* own spacing steps. Every direct block also has its outer margin reset,
|
|
795
|
+
* so the distance between two blocks is ONE number and a host can retune
|
|
796
|
+
* all of them by declaring the property once — see {@link blockRhythmCss};
|
|
797
|
+
* - `"legacy"` — the flat `spacing[4]` this page wrote inline for a host
|
|
798
|
+
* whose own layout was measured against it.
|
|
799
|
+
*
|
|
800
|
+
* The default is the NEW behaviour: 16px between a page's sections was the
|
|
801
|
+
* value a vertical `<Flex>` was given when the page was first assembled, not
|
|
802
|
+
* a rhythm anybody chose, and it is what made the blocks read as one column
|
|
803
|
+
* with no seams.
|
|
804
|
+
*/
|
|
805
|
+
readonly blockRhythm?: SearchBlockRhythm;
|
|
662
806
|
/**
|
|
663
807
|
* PIN the results toolbar under whatever chrome is above this page — the
|
|
664
808
|
* other column's half of {@link railTop}.
|
|
@@ -793,6 +937,13 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
793
937
|
* grid it could not read back.
|
|
794
938
|
*/
|
|
795
939
|
readonly resultsColumns?: ResultsColumns;
|
|
940
|
+
/**
|
|
941
|
+
* The box the results arrive into — `<SearchResultsPane reserve>`,
|
|
942
|
+
* forwarded. Here for the same reason `resultsColumns` is: the page is what
|
|
943
|
+
* a storefront mounts, and this was the last thing on it a host could only
|
|
944
|
+
* reach with `#search-page > :last-child` in its own stylesheet.
|
|
945
|
+
*/
|
|
946
|
+
readonly resultsReserve?: number | string;
|
|
796
947
|
/**
|
|
797
948
|
* Is the results caption SEEN below the sheet breakpoint —
|
|
798
949
|
* `<SearchResultsPane headingVisible>`, forwarded. Default: the host's own
|
|
@@ -804,6 +955,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
804
955
|
interface SearchPageBodyProps {
|
|
805
956
|
readonly renderCard?: SearchCardRenderer;
|
|
806
957
|
readonly resultsColumns?: ResultsColumns;
|
|
958
|
+
readonly resultsReserve?: number | string;
|
|
807
959
|
readonly resultsHeadingVisible?: boolean;
|
|
808
960
|
readonly categoryFilter?: boolean;
|
|
809
961
|
readonly resultsLead?: ReactNode;
|
|
@@ -837,6 +989,8 @@ interface SearchPageBodyProps {
|
|
|
837
989
|
readonly railFrom?: SearchRailFrom;
|
|
838
990
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
839
991
|
readonly railTop?: number | string;
|
|
992
|
+
readonly railScrollbar?: SearchRailScrollbar;
|
|
993
|
+
readonly blockRhythm?: SearchBlockRhythm;
|
|
840
994
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
841
995
|
readonly defaultFiltersOpen?: boolean;
|
|
842
996
|
readonly filtersOpen?: boolean;
|
|
@@ -1112,6 +1266,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1112
1266
|
{...(props.resultsColumns !== undefined
|
|
1113
1267
|
? { columns: props.resultsColumns }
|
|
1114
1268
|
: {})}
|
|
1269
|
+
{...(props.resultsReserve !== undefined
|
|
1270
|
+
? { reserve: props.resultsReserve }
|
|
1271
|
+
: {})}
|
|
1115
1272
|
{...(props.resultsHeadingVisible !== undefined
|
|
1116
1273
|
? { headingVisible: props.resultsHeadingVisible }
|
|
1117
1274
|
: {})}
|
|
@@ -1141,14 +1298,28 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1141
1298
|
/>
|
|
1142
1299
|
);
|
|
1143
1300
|
|
|
1301
|
+
const legacyRhythm = props.blockRhythm === "legacy";
|
|
1144
1302
|
return (
|
|
1145
1303
|
<Flex
|
|
1146
1304
|
vertical
|
|
1147
|
-
gap
|
|
1305
|
+
// ONE gap for every block on this page, from the token pair — or the
|
|
1306
|
+
// 16px this page used to state inline, for a host pinned to it.
|
|
1307
|
+
{...(legacyRhythm
|
|
1308
|
+
? { gap: spacing[4] }
|
|
1309
|
+
: { className: BLOCK_RHYTHM_CLASS })}
|
|
1148
1310
|
data-testid="search-page"
|
|
1311
|
+
data-rhythm={legacyRhythm ? "legacy" : "token"}
|
|
1149
1312
|
data-filters={showFilters ? "on" : "off"}
|
|
1150
1313
|
data-filters-layout={layout}
|
|
1151
1314
|
>
|
|
1315
|
+
{/* The rhythm's rules — see `blockRhythmCss`. Hoisted, deduped by
|
|
1316
|
+
`href`, and not mounted at all when the host asked for the old
|
|
1317
|
+
inline gap. */}
|
|
1318
|
+
{!legacyRhythm && (
|
|
1319
|
+
<style href={BLOCK_RHYTHM_STYLE_HREF} precedence="default">
|
|
1320
|
+
{blockRhythmCss()}
|
|
1321
|
+
</style>
|
|
1322
|
+
)}
|
|
1152
1323
|
{props.searchBox !== false && <SearchBox />}
|
|
1153
1324
|
{props.breadcrumb !== undefined && (
|
|
1154
1325
|
<div data-testid="search-breadcrumb">{props.breadcrumb}</div>
|
|
@@ -1264,12 +1435,23 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1264
1435
|
</>
|
|
1265
1436
|
) : showFilters ? (
|
|
1266
1437
|
<Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
|
|
1267
|
-
<div
|
|
1438
|
+
<div
|
|
1439
|
+
className={
|
|
1440
|
+
props.railScrollbar === "system"
|
|
1441
|
+
? RAIL_CLASS
|
|
1442
|
+
: `${RAIL_CLASS} ${RAIL_SCROLLBAR_CLASS}`
|
|
1443
|
+
}
|
|
1444
|
+
style={railStyle(props.railTop)}
|
|
1445
|
+
>
|
|
1268
1446
|
{/* The rail's scrollbar, in the gutter and in the token palette —
|
|
1269
|
-
see `railScrollbarCss`. Hoisted, deduped by `href`.
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1447
|
+
see `railScrollbarCss`. Hoisted, deduped by `href`. Not mounted
|
|
1448
|
+
at all under `"system"`: a sheet whose only selector is a class
|
|
1449
|
+
nothing carries is dead weight in the document. */}
|
|
1450
|
+
{props.railScrollbar !== "system" && (
|
|
1451
|
+
<style href={RAIL_STYLE_HREF} precedence="default">
|
|
1452
|
+
{railScrollbarCss()}
|
|
1453
|
+
</style>
|
|
1454
|
+
)}
|
|
1273
1455
|
{panel}
|
|
1274
1456
|
</div>
|
|
1275
1457
|
{/* ONE heading and ONE sort control. The page used to caption
|
|
@@ -1319,6 +1501,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1319
1501
|
railFrom,
|
|
1320
1502
|
filtersLayout,
|
|
1321
1503
|
railTop,
|
|
1504
|
+
railScrollbar,
|
|
1505
|
+
blockRhythm,
|
|
1322
1506
|
stickyToolbar,
|
|
1323
1507
|
defaultFiltersOpen,
|
|
1324
1508
|
filtersOpen,
|
|
@@ -1332,6 +1516,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1332
1516
|
resultsAction,
|
|
1333
1517
|
resultsHeadingLevel,
|
|
1334
1518
|
resultsColumns,
|
|
1519
|
+
resultsReserve,
|
|
1335
1520
|
resultsHeadingVisible,
|
|
1336
1521
|
dictionaryMode,
|
|
1337
1522
|
visibleGroups,
|
|
@@ -1347,6 +1532,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1347
1532
|
<SearchPageBody
|
|
1348
1533
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
1349
1534
|
{...(resultsColumns !== undefined ? { resultsColumns } : {})}
|
|
1535
|
+
{...(resultsReserve !== undefined ? { resultsReserve } : {})}
|
|
1350
1536
|
{...(resultsHeadingVisible !== undefined ? { resultsHeadingVisible } : {})}
|
|
1351
1537
|
{...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
|
|
1352
1538
|
{...(visibleGroups !== undefined ? { visibleGroups } : {})}
|
|
@@ -1381,6 +1567,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1381
1567
|
{...(railFrom !== undefined ? { railFrom } : {})}
|
|
1382
1568
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
1383
1569
|
{...(railTop !== undefined ? { railTop } : {})}
|
|
1570
|
+
{...(railScrollbar !== undefined ? { railScrollbar } : {})}
|
|
1571
|
+
{...(blockRhythm !== undefined ? { blockRhythm } : {})}
|
|
1384
1572
|
{...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
|
|
1385
1573
|
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
1386
1574
|
{...(filtersOpen !== undefined ? { filtersOpen } : {})}
|
|
@@ -487,6 +487,25 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
487
487
|
/** Widest the column of results may grow (default {@link RESULTS_MAX_WIDTH});
|
|
488
488
|
* `null` lets the container decide. */
|
|
489
489
|
readonly maxWidth?: number | null;
|
|
490
|
+
/**
|
|
491
|
+
* THE BOX THE RESULTS ARRIVE INTO — a block-size floor the pane holds while
|
|
492
|
+
* the first answer is in flight, and drops the moment the rows land.
|
|
493
|
+
*
|
|
494
|
+
* The pane cannot know how tall its own page will be (the row count is the
|
|
495
|
+
* host's `limit` and the card height is the host's card), so this is a
|
|
496
|
+
* number the host measures once for its own feed — `limit × card + gaps` —
|
|
497
|
+
* and it stops the fold below the results from rising into the space the
|
|
498
|
+
* feed is about to take.
|
|
499
|
+
*
|
|
500
|
+
* What it replaces is a stylesheet rule aimed at this pane with the only
|
|
501
|
+
* hold a consumer had on it: `#search-page > :last-child`, a selector that
|
|
502
|
+
* silently starts addressing something else the day this page grows a
|
|
503
|
+
* seventh child. The pane's root now also carries
|
|
504
|
+
* `data-testid="search-results-pane"`, so a host that wants its own rule
|
|
505
|
+
* has a handle that is part of this pair's surface instead of a count of
|
|
506
|
+
* siblings.
|
|
507
|
+
*/
|
|
508
|
+
readonly reserve?: number | string;
|
|
490
509
|
/**
|
|
491
510
|
* How the loaded rows are arranged: as many card columns as fit
|
|
492
511
|
* (`"grid"`, the default) or one wide row each (`"list"`). The view SWITCH
|
|
@@ -713,6 +732,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
713
732
|
return (
|
|
714
733
|
<SkinTheme
|
|
715
734
|
surface="base"
|
|
735
|
+
data-testid="search-results-pane"
|
|
716
736
|
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
717
737
|
style={{
|
|
718
738
|
width: "100%",
|
|
@@ -721,7 +741,16 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
721
741
|
>
|
|
722
742
|
<SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
|
|
723
743
|
{(bag) => (
|
|
724
|
-
<Flex
|
|
744
|
+
<Flex
|
|
745
|
+
vertical
|
|
746
|
+
gap={spacing[4]}
|
|
747
|
+
// The box the results arrive into — see `reserve`. Only until the
|
|
748
|
+
// FIRST answer lands: a floor under rows already on screen would
|
|
749
|
+
// hold a gap open under a short page for the rest of the session.
|
|
750
|
+
{...(props.reserve !== undefined && bag.state.status === "loading"
|
|
751
|
+
? { style: { minBlockSize: props.reserve } }
|
|
752
|
+
: {})}
|
|
753
|
+
>
|
|
725
754
|
{props.lead !== undefined && (
|
|
726
755
|
<div data-testid="search-results-lead">{props.lead}</div>
|
|
727
756
|
)}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* That is the whole defect this file used to carry: the one sort a person
|
|
16
16
|
* would most want on a phone was greyed out with its explanation in a hover.
|
|
17
17
|
*/
|
|
18
|
-
import type { ReactElement } from "react";
|
|
18
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
19
19
|
import { Flex, Select, Typography } from "antd";
|
|
20
20
|
import { actionAvailable, actionBlocked, useT } from "@stapel/core";
|
|
21
21
|
import type { ActionAvailability } from "@stapel/core";
|
|
@@ -34,6 +34,61 @@ import { sortLabelKey } from "./sortLabels.js";
|
|
|
34
34
|
*/
|
|
35
35
|
export const SORT_SELECT_MIN_WIDTH = 200;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The select's own chrome around its label: antd's two inline paddings plus
|
|
39
|
+
* the caret and its margin. Added to the compact form's sizer (below), which
|
|
40
|
+
* measures a bare label.
|
|
41
|
+
*/
|
|
42
|
+
export const SORT_SELECT_CHROME = 40;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ── THE COMPACT CONTROL'S BOX, HELD FROM THE FIRST FRAME ────────────────────
|
|
46
|
+
*
|
|
47
|
+
* The compact arm draws `value={active ?? null}`, and `active` is only known
|
|
48
|
+
* once the page in cache reports the sort the SERVER applied (see
|
|
49
|
+
* {@link useAppliedSort}) — for an address that names no `sort`, that is a
|
|
50
|
+
* whole round trip after the first paint. So the control rendered as a bare
|
|
51
|
+
* caret and then GREW by the width of its label: measured on a category leaf,
|
|
52
|
+
* the caret's own box moved from x=22 to x=137, 115px, the largest single
|
|
53
|
+
* term in that page's layout shift. `minWidth: 0` was written inline, so no
|
|
54
|
+
* consumer stylesheet could hold the box either.
|
|
55
|
+
*
|
|
56
|
+
* The floor is not {@link SORT_SELECT_MIN_WIDTH}: 200px is the desktop arm's
|
|
57
|
+
* number and would wrap a two-control toolbar onto two rows at 390px. It is
|
|
58
|
+
* the width of the longest label THIS control can be asked to show, at the
|
|
59
|
+
* font it will show it in — so it is measured by the browser rather than
|
|
60
|
+
* guessed in pixels, and it is right in every locale (a Russian sort label is
|
|
61
|
+
* half again as long as its English original).
|
|
62
|
+
*
|
|
63
|
+
* One grid cell, two children stacked in it: an `aria-hidden` sizer carrying
|
|
64
|
+
* the longest label, and the select itself. The cell is as wide as the sizer,
|
|
65
|
+
* the select fills it, and neither depends on `active`.
|
|
66
|
+
*/
|
|
67
|
+
const COMPACT_WRAP: CSSProperties = {
|
|
68
|
+
display: "inline-grid",
|
|
69
|
+
flex: "0 1 auto",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const COMPACT_SIZER: CSSProperties = {
|
|
73
|
+
gridArea: "1 / 1",
|
|
74
|
+
visibility: "hidden",
|
|
75
|
+
blockSize: 0,
|
|
76
|
+
overflow: "hidden",
|
|
77
|
+
whiteSpace: "nowrap",
|
|
78
|
+
pointerEvents: "none",
|
|
79
|
+
paddingInline: SORT_SELECT_CHROME,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const COMPACT_SELECT: CSSProperties = { gridArea: "1 / 1", minWidth: 0 };
|
|
83
|
+
|
|
84
|
+
/** The longest of the labels the control can display, which is the one the
|
|
85
|
+
* sizer holds. Ties keep the first — they are the same width. */
|
|
86
|
+
export function longestSortLabel(labels: readonly string[]): string {
|
|
87
|
+
return labels.reduce((longest, label) =>
|
|
88
|
+
label.length > longest.length ? label : longest
|
|
89
|
+
, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
37
92
|
/** Why `sort=distance` is refused without a centre — the server's own code, so
|
|
38
93
|
* the control and the 400 it would have earned say the same sentence. */
|
|
39
94
|
const SORT_DISTANCE_BLOCKED = "error.400.search_sort_needs_center";
|
|
@@ -49,9 +104,11 @@ export interface SortSelectProps {
|
|
|
49
104
|
*
|
|
50
105
|
* - the "Sort" caption goes (the select already shows a sort by name; the
|
|
51
106
|
* accessible name keeps the word);
|
|
52
|
-
* - the {@link SORT_SELECT_MIN_WIDTH} floor goes
|
|
107
|
+
* - the {@link SORT_SELECT_MIN_WIDTH} floor goes — the control shares one
|
|
53
108
|
* row with whatever the surface puts beside it instead of pushing it to
|
|
54
|
-
* the next line
|
|
109
|
+
* the next line — and is replaced by a floor the width of this control's
|
|
110
|
+
* OWN longest label, so the box does not grow when the answer names the
|
|
111
|
+
* sort (see `COMPACT_WRAP`);
|
|
55
112
|
* - the line under the control goes. The blocked option's REASON does not:
|
|
56
113
|
* it is on the option itself at every width now (see `optionsFor`), and
|
|
57
114
|
* what the compact form drops is the second, separate copy of it.
|
|
@@ -110,14 +167,25 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
110
167
|
* accessible description of the SELECT, the other is the label of the OPTION
|
|
111
168
|
* that is refused, and they are read in different moments.
|
|
112
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* The names this control can DISPLAY, which is what the compact form's
|
|
172
|
+
* sizer is measured on. The blocked row's appended reason is deliberately
|
|
173
|
+
* not among them: it reaches the CLOSED control only for a sort the address
|
|
174
|
+
* itself names, and a sort in the address is known from the first frame —
|
|
175
|
+
* so it can widen the box but can never move it.
|
|
176
|
+
*/
|
|
177
|
+
const plainLabels = values.map((value) => {
|
|
178
|
+
const key = sortLabelKey(value);
|
|
179
|
+
return key !== undefined ? t(key) : value;
|
|
180
|
+
});
|
|
181
|
+
|
|
113
182
|
const optionsFor = (): {
|
|
114
183
|
readonly value: string;
|
|
115
184
|
readonly label: string;
|
|
116
185
|
readonly disabled: boolean;
|
|
117
186
|
}[] =>
|
|
118
|
-
values.map((value) => {
|
|
119
|
-
const
|
|
120
|
-
const label = key !== undefined ? t(key) : value;
|
|
187
|
+
values.map((value, index) => {
|
|
188
|
+
const label = plainLabels[index] ?? value;
|
|
121
189
|
const blocked = value === "distance" && !hasCentre;
|
|
122
190
|
return {
|
|
123
191
|
value,
|
|
@@ -128,20 +196,23 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
128
196
|
|
|
129
197
|
if (props.compact === true) {
|
|
130
198
|
return (
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
199
|
+
<div style={COMPACT_WRAP} data-testid="search-sort-compact">
|
|
200
|
+
{/* The box, not a caption — see `COMPACT_WRAP`. */}
|
|
201
|
+
<span aria-hidden="true" data-testid="search-sort-sizer" style={COMPACT_SIZER}>
|
|
202
|
+
{longestSortLabel(plainLabels)}
|
|
203
|
+
</span>
|
|
204
|
+
<Select<string>
|
|
205
|
+
data-testid="search-sort"
|
|
206
|
+
data-stapel-gated={hasCentre ? "available" : "blocked"}
|
|
207
|
+
aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
|
|
208
|
+
style={COMPACT_SELECT}
|
|
209
|
+
value={active ?? null}
|
|
210
|
+
onChange={(next) => {
|
|
211
|
+
setSort(next);
|
|
212
|
+
}}
|
|
213
|
+
options={optionsFor()}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
145
216
|
);
|
|
146
217
|
}
|
|
147
218
|
|
package/src/default/index.ts
CHANGED
|
@@ -42,7 +42,13 @@
|
|
|
42
42
|
// ── surfaces ────────────────────────────────────────────────────────────────
|
|
43
43
|
export {
|
|
44
44
|
SearchPage,
|
|
45
|
+
BLOCK_GAP_COMPACT_VAR,
|
|
46
|
+
BLOCK_GAP_VAR,
|
|
47
|
+
BLOCK_RHYTHM_CLASS,
|
|
48
|
+
BLOCK_RHYTHM_STYLE_HREF,
|
|
49
|
+
blockRhythmCss,
|
|
45
50
|
RAIL_CLASS,
|
|
51
|
+
RAIL_SCROLLBAR_CLASS,
|
|
46
52
|
RAIL_STYLE_HREF,
|
|
47
53
|
railScrollbarCss,
|
|
48
54
|
railStyle,
|
|
@@ -53,7 +59,9 @@ export type {
|
|
|
53
59
|
SearchFiltersHeader,
|
|
54
60
|
SearchFiltersHeaderSlotProps,
|
|
55
61
|
SearchFiltersOpenReason,
|
|
62
|
+
SearchBlockRhythm,
|
|
56
63
|
SearchRailFrom,
|
|
64
|
+
SearchRailScrollbar,
|
|
57
65
|
} from "./SearchPage.js";
|
|
58
66
|
|
|
59
67
|
export {
|
|
@@ -93,7 +101,6 @@ export {
|
|
|
93
101
|
CHIP_BAND_ORDER,
|
|
94
102
|
CHIP_ROW_CAP,
|
|
95
103
|
CHIP_ROW_CLASS,
|
|
96
|
-
CHIP_ROW_MIN_HEIGHT,
|
|
97
104
|
CHIP_ROW_STYLE_HREF,
|
|
98
105
|
appliedChipTestId,
|
|
99
106
|
appliedRowMinHeight,
|
|
@@ -101,6 +108,7 @@ export {
|
|
|
101
108
|
capChipRow,
|
|
102
109
|
categoryLeaf,
|
|
103
110
|
chipRowCss,
|
|
111
|
+
chipRowMinHeight,
|
|
104
112
|
orderChipFilters,
|
|
105
113
|
rangeChipText,
|
|
106
114
|
rangeLabelSource,
|
|
@@ -179,7 +187,12 @@ export type { RankingDisclosurePaneProps } from "./RankingDisclosurePane.js";
|
|
|
179
187
|
// ── controls, exported so a host can compose its own layout ─────────────────
|
|
180
188
|
export { SearchBox } from "./SearchBox.js";
|
|
181
189
|
export type { SearchBoxProps } from "./SearchBox.js";
|
|
182
|
-
export {
|
|
190
|
+
export {
|
|
191
|
+
SortSelect,
|
|
192
|
+
SORT_SELECT_CHROME,
|
|
193
|
+
SORT_SELECT_MIN_WIDTH,
|
|
194
|
+
longestSortLabel,
|
|
195
|
+
} from "./SortSelect.js";
|
|
183
196
|
export type { SortSelectProps } from "./SortSelect.js";
|
|
184
197
|
export { ViewSwitch, SEARCH_BUILTIN_VIEWS, resolveView } from "./ViewSwitch.js";
|
|
185
198
|
export type { ViewSwitchProps, SearchView, SearchResultsLayout } from "./ViewSwitch.js";
|