@stapel/search-react 0.37.0 → 0.39.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 +92 -0
- package/dist/default/PartitionChips.d.ts +55 -0
- package/dist/default/PartitionChips.d.ts.map +1 -1
- package/dist/default/PartitionChips.js +59 -4
- package/dist/default/PartitionChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts +101 -15
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +131 -38
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +3 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +4 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/PartitionChips.tsx +164 -4
- package/src/default/SearchPage.tsx +215 -40
- package/src/default/index.ts +13 -1
|
@@ -57,7 +57,7 @@ import type {
|
|
|
57
57
|
} from "react";
|
|
58
58
|
import { Button, Segmented, Typography } from "antd";
|
|
59
59
|
import { useT } from "@stapel/core";
|
|
60
|
-
import { radii, spacing } from "@stapel/tokens";
|
|
60
|
+
import { cssVar, radii, spacing } from "@stapel/tokens";
|
|
61
61
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
62
62
|
|
|
63
63
|
/** One child of a partitioned category. `path` is the slash-joined id path
|
|
@@ -81,10 +81,48 @@ export interface PartitionChild {
|
|
|
81
81
|
*
|
|
82
82
|
* Omit it and nothing is drawn — an absent count is not a zero, and a
|
|
83
83
|
* section whose total nobody asked for must not be captioned "0".
|
|
84
|
+
*
|
|
85
|
+
* Ignored on a POINTER — see {@link linked}.
|
|
84
86
|
*/
|
|
85
87
|
readonly count?: number;
|
|
88
|
+
/**
|
|
89
|
+
* This entry is a POINTER to another category, not a section of THIS
|
|
90
|
+
* template (`CategoryChild.linked`, stapel-categories 0.22.0).
|
|
91
|
+
*
|
|
92
|
+
* A partition is one template split by a value its children's names express
|
|
93
|
+
* — new / used / for rent. A pointer is a different branch of the catalogue
|
|
94
|
+
* that an operator drew among these children so a person can reach it from
|
|
95
|
+
* here. It is not one of the halves, it does not narrow this feed, and it
|
|
96
|
+
* has NO count of its own: the number beside it is the number of listings in
|
|
97
|
+
* somebody else's category, which is why the storefront's
|
|
98
|
+
* `/c/transport-avtomobili` read `All | New 0 | Used 3 | Car rental 0`
|
|
99
|
+
* — two of those zeroes were a partition's real emptiness and one was a
|
|
100
|
+
* question nobody had asked.
|
|
101
|
+
*
|
|
102
|
+
* So a linked entry never becomes a radio, never carries a count, and never
|
|
103
|
+
* matches {@link PartitionChipsProps.value} — a stale address naming one
|
|
104
|
+
* leaves the row on its parent chip rather than lighting a pointer up as the
|
|
105
|
+
* chosen section. It is drawn AFTER the partitions as a link, or not at all:
|
|
106
|
+
* see {@link PartitionChipsProps.linkedChildren}.
|
|
107
|
+
*/
|
|
108
|
+
readonly linked?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* WHERE a pointer leads — the target's own address, as the host builds it
|
|
111
|
+
* (`/c/<slug>`). Read only on a {@link linked} entry.
|
|
112
|
+
*
|
|
113
|
+
* The pointer's `path` is an id path into the CATALOGUE, and following it
|
|
114
|
+
* as a `category` filter is exactly the confusion this shape exists to end:
|
|
115
|
+
* a pointer is a destination, so the chip is a real `<a href>` that
|
|
116
|
+
* navigates, with no `f=` and no state change on this page. A linked entry
|
|
117
|
+
* with no `href` is not drawn — a link with no address is not a link.
|
|
118
|
+
*/
|
|
119
|
+
readonly href?: string;
|
|
86
120
|
}
|
|
87
121
|
|
|
122
|
+
/** What the row does with a POINTER among its children — see
|
|
123
|
+
* {@link PartitionChipsProps.linkedChildren}. */
|
|
124
|
+
export type PartitionLinkedChildren = "chip" | "none";
|
|
125
|
+
|
|
88
126
|
export interface PartitionChipsProps {
|
|
89
127
|
/** The children, in the order the catalogue declares them. */
|
|
90
128
|
readonly items: readonly PartitionChild[];
|
|
@@ -109,6 +147,24 @@ export interface PartitionChipsProps {
|
|
|
109
147
|
* browser's own `input[type=radio]` in `segmented`.
|
|
110
148
|
*/
|
|
111
149
|
readonly variant?: "chips" | "segmented";
|
|
150
|
+
/**
|
|
151
|
+
* What the row does with a POINTER among its children. Default `"chip"`.
|
|
152
|
+
*
|
|
153
|
+
* - `"chip"` — drawn AFTER the partitions as an outlined link chip: the
|
|
154
|
+
* target's name and a trailing arrow, no count, a real `<a href>` that
|
|
155
|
+
* navigates to the target rather than filtering this page. Outlined and
|
|
156
|
+
* separate on purpose — it is not one of the choices, and a control that
|
|
157
|
+
* looks like the others while doing something else is worse than one
|
|
158
|
+
* that looks different;
|
|
159
|
+
* - `"none"` — not drawn here at all, for a page whose TILE STAGE already
|
|
160
|
+
* shows the same pointer as a tile. One destination offered twice, a row
|
|
161
|
+
* apart, is a person wondering what the difference is.
|
|
162
|
+
*
|
|
163
|
+
* Either way a pointer is out of the partition semantics: no radio, no
|
|
164
|
+
* count, never the chosen section. This prop only decides whether the link
|
|
165
|
+
* is offered in this row.
|
|
166
|
+
*/
|
|
167
|
+
readonly linkedChildren?: PartitionLinkedChildren;
|
|
112
168
|
}
|
|
113
169
|
|
|
114
170
|
/**
|
|
@@ -127,6 +183,23 @@ const ROW: CSSProperties = {
|
|
|
127
183
|
gap: spacing[2],
|
|
128
184
|
};
|
|
129
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The pointer chip: the partition pill's geometry, OUTLINED — a hairline and
|
|
188
|
+
* no fill, so it reads as a way out of this page rather than as one of the
|
|
189
|
+
* choices on it.
|
|
190
|
+
*/
|
|
191
|
+
const POINTER_CHIP: CSSProperties = {
|
|
192
|
+
display: "inline-flex",
|
|
193
|
+
alignItems: "center",
|
|
194
|
+
gap: spacing[1],
|
|
195
|
+
borderRadius: radii.full,
|
|
196
|
+
border: `1px solid ${cssVar("border")}`,
|
|
197
|
+
paddingBlock: spacing[1],
|
|
198
|
+
paddingInline: spacing[3],
|
|
199
|
+
color: cssVar("text"),
|
|
200
|
+
lineHeight: 1.4,
|
|
201
|
+
};
|
|
202
|
+
|
|
130
203
|
/** The keys that move the choice, in both variants. */
|
|
131
204
|
const ARROW_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
|
132
205
|
|
|
@@ -189,6 +262,68 @@ function ChildLabel(props: { readonly child: PartitionChild }): ReactElement {
|
|
|
189
262
|
);
|
|
190
263
|
}
|
|
191
264
|
|
|
265
|
+
/**
|
|
266
|
+
* The pointer chip's trailing mark — an arrow leaving to the right, the one
|
|
267
|
+
* glyph that says "this goes somewhere else" rather than "this narrows what
|
|
268
|
+
* is here".
|
|
269
|
+
*
|
|
270
|
+
* Drawn inline in `currentColor`, like every other glyph in this skin
|
|
271
|
+
* (`ChevronGlyph`, `PinGlyph`, `SlidersGlyph`): this package ships no icon set
|
|
272
|
+
* and one arrow is not the reason to take one. `aria-hidden`, because the
|
|
273
|
+
* chip's accessible name is the target's own — a screen reader announcing an
|
|
274
|
+
* arrow after it would be reading the decoration.
|
|
275
|
+
*/
|
|
276
|
+
function PointerGlyph(): ReactElement {
|
|
277
|
+
return (
|
|
278
|
+
<svg
|
|
279
|
+
aria-hidden="true"
|
|
280
|
+
focusable="false"
|
|
281
|
+
viewBox="0 0 16 16"
|
|
282
|
+
width="1em"
|
|
283
|
+
height="1em"
|
|
284
|
+
style={{ flex: "0 0 auto" }}
|
|
285
|
+
>
|
|
286
|
+
<path
|
|
287
|
+
d="M6 3.5 10.5 8 6 12.5"
|
|
288
|
+
fill="none"
|
|
289
|
+
stroke="currentColor"
|
|
290
|
+
strokeWidth="1.75"
|
|
291
|
+
strokeLinecap="round"
|
|
292
|
+
strokeLinejoin="round"
|
|
293
|
+
/>
|
|
294
|
+
</svg>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The pointers, after the partitions and outside the radiogroup.
|
|
300
|
+
*
|
|
301
|
+
* OUTSIDE is not a layout preference: a `role="radiogroup"` whose children
|
|
302
|
+
* include a link announces a choice that has an option you cannot choose. The
|
|
303
|
+
* pointers are their own row, and each one is an ordinary anchor — a
|
|
304
|
+
* middle-click, a ctrl/cmd-click and "open in a new tab" all work, which is
|
|
305
|
+
* the whole difference between a destination and a filter.
|
|
306
|
+
*/
|
|
307
|
+
function PointerChips(props: {
|
|
308
|
+
readonly items: readonly PartitionChild[];
|
|
309
|
+
}): ReactElement {
|
|
310
|
+
return (
|
|
311
|
+
<div style={ROW} data-testid="partition-links">
|
|
312
|
+
{props.items.map((item) => (
|
|
313
|
+
<a
|
|
314
|
+
key={item.path}
|
|
315
|
+
href={item.href}
|
|
316
|
+
style={POINTER_CHIP}
|
|
317
|
+
data-testid={`partition-link-${item.path}`}
|
|
318
|
+
>
|
|
319
|
+
{item.name}
|
|
320
|
+
<PointerGlyph />
|
|
321
|
+
</a>
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
192
327
|
/** The row's cells, as `[value, label]` — the parent first, then the
|
|
193
328
|
* children in catalogue order. */
|
|
194
329
|
function cells(
|
|
@@ -206,8 +341,19 @@ function cells(
|
|
|
206
341
|
export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
207
342
|
const t = useT();
|
|
208
343
|
const row = useRef<HTMLDivElement>(null);
|
|
344
|
+
/* THE TWO KINDS OF CHILD, SPLIT ONCE. Everything below the split — the
|
|
345
|
+
cells, the roving stop, the value lookup, the arrow keys — sees only the
|
|
346
|
+
SECTIONS, which is what keeps a pointer out of the partition's semantics
|
|
347
|
+
rather than out of one rendering of them. */
|
|
348
|
+
const sections = props.items.filter((item) => item.linked !== true);
|
|
349
|
+
const pointers =
|
|
350
|
+
props.linkedChildren === "none"
|
|
351
|
+
? []
|
|
352
|
+
: props.items.filter(
|
|
353
|
+
(item) => item.linked === true && item.href !== undefined
|
|
354
|
+
);
|
|
209
355
|
const options = cells(
|
|
210
|
-
|
|
356
|
+
sections,
|
|
211
357
|
props.allLabel ?? t(SEARCH_I18N_KEYS.partitionAll)
|
|
212
358
|
);
|
|
213
359
|
|
|
@@ -282,6 +428,20 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
|
282
428
|
|
|
283
429
|
const name = props.label ?? t(SEARCH_I18N_KEYS.partitionLabel);
|
|
284
430
|
|
|
431
|
+
/* The pointers ride BESIDE whichever control was drawn, never inside it —
|
|
432
|
+
see `PointerChips`. A fragment rather than a wrapper element: this row is
|
|
433
|
+
mounted in a vertical `<Flex>` that already spaces its children, and an
|
|
434
|
+
extra box here would take that gap away from the row it wraps. */
|
|
435
|
+
const withPointers = (control: ReactElement): ReactElement =>
|
|
436
|
+
pointers.length === 0 ? (
|
|
437
|
+
control
|
|
438
|
+
) : (
|
|
439
|
+
<>
|
|
440
|
+
{control}
|
|
441
|
+
<PointerChips items={pointers} />
|
|
442
|
+
</>
|
|
443
|
+
);
|
|
444
|
+
|
|
285
445
|
if (props.variant === "segmented") {
|
|
286
446
|
// antd's own control: `.ant-segmented`, one `input[type=radio]` per cell
|
|
287
447
|
// under a shared `name`, the selected cell's `checked`, and the arrow keys
|
|
@@ -289,7 +449,7 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
|
289
449
|
// `aria-label` reach the root because the component spreads what it is
|
|
290
450
|
// given over its own defaults (which are `radiogroup` and the string
|
|
291
451
|
// "segmented control").
|
|
292
|
-
return (
|
|
452
|
+
return withPointers(
|
|
293
453
|
<Segmented
|
|
294
454
|
block
|
|
295
455
|
size="small"
|
|
@@ -328,7 +488,7 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
|
328
488
|
);
|
|
329
489
|
}
|
|
330
490
|
|
|
331
|
-
return (
|
|
491
|
+
return withPointers(
|
|
332
492
|
<div
|
|
333
493
|
style={ROW}
|
|
334
494
|
data-variant="chips"
|
|
@@ -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}.
|
|
@@ -845,6 +989,8 @@ interface SearchPageBodyProps {
|
|
|
845
989
|
readonly railFrom?: SearchRailFrom;
|
|
846
990
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
847
991
|
readonly railTop?: number | string;
|
|
992
|
+
readonly railScrollbar?: SearchRailScrollbar;
|
|
993
|
+
readonly blockRhythm?: SearchBlockRhythm;
|
|
848
994
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
849
995
|
readonly defaultFiltersOpen?: boolean;
|
|
850
996
|
readonly filtersOpen?: boolean;
|
|
@@ -1152,14 +1298,28 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1152
1298
|
/>
|
|
1153
1299
|
);
|
|
1154
1300
|
|
|
1301
|
+
const legacyRhythm = props.blockRhythm === "legacy";
|
|
1155
1302
|
return (
|
|
1156
1303
|
<Flex
|
|
1157
1304
|
vertical
|
|
1158
|
-
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 })}
|
|
1159
1310
|
data-testid="search-page"
|
|
1311
|
+
data-rhythm={legacyRhythm ? "legacy" : "token"}
|
|
1160
1312
|
data-filters={showFilters ? "on" : "off"}
|
|
1161
1313
|
data-filters-layout={layout}
|
|
1162
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
|
+
)}
|
|
1163
1323
|
{props.searchBox !== false && <SearchBox />}
|
|
1164
1324
|
{props.breadcrumb !== undefined && (
|
|
1165
1325
|
<div data-testid="search-breadcrumb">{props.breadcrumb}</div>
|
|
@@ -1275,12 +1435,23 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1275
1435
|
</>
|
|
1276
1436
|
) : showFilters ? (
|
|
1277
1437
|
<Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
|
|
1278
|
-
<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
|
+
>
|
|
1279
1446
|
{/* The rail's scrollbar, in the gutter and in the token palette —
|
|
1280
|
-
see `railScrollbarCss`. Hoisted, deduped by `href`.
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
+
)}
|
|
1284
1455
|
{panel}
|
|
1285
1456
|
</div>
|
|
1286
1457
|
{/* ONE heading and ONE sort control. The page used to caption
|
|
@@ -1330,6 +1501,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1330
1501
|
railFrom,
|
|
1331
1502
|
filtersLayout,
|
|
1332
1503
|
railTop,
|
|
1504
|
+
railScrollbar,
|
|
1505
|
+
blockRhythm,
|
|
1333
1506
|
stickyToolbar,
|
|
1334
1507
|
defaultFiltersOpen,
|
|
1335
1508
|
filtersOpen,
|
|
@@ -1394,6 +1567,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1394
1567
|
{...(railFrom !== undefined ? { railFrom } : {})}
|
|
1395
1568
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
1396
1569
|
{...(railTop !== undefined ? { railTop } : {})}
|
|
1570
|
+
{...(railScrollbar !== undefined ? { railScrollbar } : {})}
|
|
1571
|
+
{...(blockRhythm !== undefined ? { blockRhythm } : {})}
|
|
1397
1572
|
{...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
|
|
1398
1573
|
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
1399
1574
|
{...(filtersOpen !== undefined ? { filtersOpen } : {})}
|
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 {
|
|
@@ -159,7 +167,11 @@ export {
|
|
|
159
167
|
} from "./PopularValues.js";
|
|
160
168
|
export type { PopularValuesProps } from "./PopularValues.js";
|
|
161
169
|
export { PartitionChips } from "./PartitionChips.js";
|
|
162
|
-
export type {
|
|
170
|
+
export type {
|
|
171
|
+
PartitionChild,
|
|
172
|
+
PartitionChipsProps,
|
|
173
|
+
PartitionLinkedChildren,
|
|
174
|
+
} from "./PartitionChips.js";
|
|
163
175
|
|
|
164
176
|
export {
|
|
165
177
|
FacetPanelPane,
|