@stapel/search-react 0.7.0 → 0.8.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/default/FacetGroupControl.d.ts +40 -0
  3. package/dist/default/FacetGroupControl.d.ts.map +1 -0
  4. package/dist/default/FacetGroupControl.js +176 -0
  5. package/dist/default/FacetGroupControl.js.map +1 -0
  6. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  7. package/dist/default/FacetPanelPane.js +3 -9
  8. package/dist/default/FacetPanelPane.js.map +1 -1
  9. package/dist/default/FilterChips.d.ts +28 -0
  10. package/dist/default/FilterChips.d.ts.map +1 -0
  11. package/dist/default/FilterChips.js +204 -0
  12. package/dist/default/FilterChips.js.map +1 -0
  13. package/dist/default/SearchPage.d.ts +40 -0
  14. package/dist/default/SearchPage.d.ts.map +1 -1
  15. package/dist/default/SearchPage.js +45 -9
  16. package/dist/default/SearchPage.js.map +1 -1
  17. package/dist/default/SearchResultsPane.d.ts +19 -0
  18. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  19. package/dist/default/SearchResultsPane.js +15 -2
  20. package/dist/default/SearchResultsPane.js.map +1 -1
  21. package/dist/default/ViewSwitch.d.ts +66 -0
  22. package/dist/default/ViewSwitch.d.ts.map +1 -0
  23. package/dist/default/ViewSwitch.js +49 -0
  24. package/dist/default/ViewSwitch.js.map +1 -0
  25. package/dist/default/index.d.ts +6 -0
  26. package/dist/default/index.d.ts.map +1 -1
  27. package/dist/default/index.js +3 -0
  28. package/dist/default/index.js.map +1 -1
  29. package/dist/i18n/es.d.ts.map +1 -1
  30. package/dist/i18n/es.js +8 -0
  31. package/dist/i18n/es.js.map +1 -1
  32. package/dist/i18n/keys.d.ts +20 -0
  33. package/dist/i18n/keys.d.ts.map +1 -1
  34. package/dist/i18n/keys.js +29 -0
  35. package/dist/i18n/keys.js.map +1 -1
  36. package/dist/i18n/ru.d.ts.map +1 -1
  37. package/dist/i18n/ru.js +8 -0
  38. package/dist/i18n/ru.js.map +1 -1
  39. package/llms.txt +2 -1
  40. package/manifest.json +31 -2
  41. package/nav-manifest.json +1 -1
  42. package/package.json +6 -6
  43. package/src/analytics/generated/events.json +1 -1
  44. package/src/default/FacetGroupControl.tsx +312 -0
  45. package/src/default/FacetPanelPane.tsx +11 -57
  46. package/src/default/FilterChips.tsx +415 -0
  47. package/src/default/SearchPage.tsx +118 -15
  48. package/src/default/SearchResultsPane.tsx +39 -2
  49. package/src/default/ViewSwitch.tsx +147 -0
  50. package/src/default/index.ts +17 -0
  51. package/src/i18n/es.ts +9 -0
  52. package/src/i18n/keys.ts +31 -0
  53. package/src/i18n/ru.ts +9 -0
@@ -37,7 +37,6 @@ import type { ReactElement, ReactNode } from "react";
37
37
  import {
38
38
  Alert,
39
39
  Button,
40
- Checkbox,
41
40
  Divider,
42
41
  Flex,
43
42
  InputNumber,
@@ -56,7 +55,7 @@ import type { FeatureDef } from "@stapel/attributes-react";
56
55
  import type { SearchGeo } from "../api/types.js";
57
56
  import { FacetPanel } from "../headless/FacetPanel.js";
58
57
  import { useSearchState } from "../headless/SearchStateProvider.js";
59
- import type { FacetGroup, FacetOption } from "../state/facets.js";
58
+ import { FacetGroupControl } from "./FacetGroupControl.js";
60
59
  import { buildRangeGroups } from "../state/ranges.js";
61
60
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
62
61
  import { LanguageSelect } from "./LanguageSelect.js";
@@ -102,45 +101,6 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
102
101
  readonly heading?: ReactNode;
103
102
  }
104
103
 
105
- function OptionRow(props: {
106
- group: FacetGroup;
107
- option: FacetOption;
108
- onToggle: (slug: string, value: string) => void;
109
- }): ReactElement {
110
- const t = useT();
111
- const { option, group } = props;
112
- return (
113
- <Flex justify="space-between" align="center" gap={spacing[2]}>
114
- <Checkbox
115
- checked={option.selected}
116
- data-testid={`facet-option-${group.slug}-${option.value}`}
117
- data-analytics="none"
118
- data-analytics-reason="a filter is a read, not a flow step"
119
- onChange={() => {
120
- props.onToggle(group.slug, option.value);
121
- }}
122
- >
123
- {option.label}
124
- </Checkbox>
125
- {option.count === null ? (
126
- <Typography.Text
127
- type="secondary"
128
- data-testid={`facet-count-${group.slug}-${option.value}`}
129
- >
130
- {t(SEARCH_I18N_KEYS.facetsNotCounted)}
131
- </Typography.Text>
132
- ) : (
133
- <Typography.Text
134
- type="secondary"
135
- data-testid={`facet-count-${group.slug}-${option.value}`}
136
- >
137
- {option.count}
138
- </Typography.Text>
139
- )}
140
- </Flex>
141
- );
142
- }
143
-
144
104
  /** The category constraint: the host's control, or the door out of it. */
145
105
  function CategoryFilter(props: {
146
106
  render?: (slot: CategoryFilterSlotProps) => ReactNode;
@@ -418,26 +378,20 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
418
378
  the desktop panel, once as the range row and once as a
419
379
  label over air. The skipped Alert above already names it;
420
380
  a heading with no control under it names nothing. */}
381
+ {/* Each group draws itself the way its own schema says:
382
+ pills for a single-choice facet, indented children for a
383
+ hierarchical one, a fold for a long one. The panel does
384
+ not decide — `facetGroupShape` reads the same config keys
385
+ the attributes editor reads, so a facet cannot look one
386
+ way here and another way in the composer. */}
421
387
  {groups
422
388
  .filter((group) => group.options.length > 0)
423
389
  .map((group) => (
424
- <Flex
425
- vertical
426
- gap={spacing[1]}
390
+ <FacetGroupControl
427
391
  key={group.slug}
428
- data-testid={`facet-group-${group.slug}`}
429
- data-counted={group.counted ? "true" : "false"}
430
- >
431
- <Typography.Text strong>{group.label}</Typography.Text>
432
- {group.options.map((option) => (
433
- <OptionRow
434
- key={option.value}
435
- group={group}
436
- option={option}
437
- onToggle={bag.toggle}
438
- />
439
- ))}
440
- </Flex>
392
+ group={group}
393
+ onToggle={bag.toggle}
394
+ />
441
395
  ))}
442
396
  <Typography.Text type="secondary">
443
397
  {t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
@@ -0,0 +1,415 @@
1
+ /**
2
+ * `<FilterChips>` — the phone's filter row: one horizontally scrolling line of
3
+ * chips, each of which opens its OWN picker.
4
+ *
5
+ * ## What it replaces, and why the replacement is not cosmetic
6
+ *
7
+ * The phone filter path was a single full-width "Filters (3)" button that
8
+ * opened the whole panel. Everything about a search — the category, the price,
9
+ * the brand, where — was behind one tap onto a sheet you then had to scroll,
10
+ * and NOTHING about what was already applied was visible on the results page
11
+ * itself except a number in brackets. The chips row states the filters ON the
12
+ * page: the ones you have set read as set, the ones you have not are one tap
13
+ * from their own small picker, and the whole panel is still there behind the
14
+ * leading chip for the person who wants all of it at once.
15
+ *
16
+ * ## The row scrolls; the PAGE does not
17
+ *
18
+ * A row of chips wider than a 390px phone is the point — it is how the row
19
+ * holds eight filters. What must not happen is the page's own body growing a
20
+ * horizontal scrollbar, which is what a `flex-wrap: nowrap` row without an
21
+ * `overflow-x` owner does: the chips push the document wider, every other
22
+ * element on the page slides, and the visitor is left rubber-banding the whole
23
+ * screen sideways to read a price. So the row owns its overflow
24
+ * (`overflow-x: auto`), contains its overscroll so a flick past the last chip
25
+ * does not start scrolling the page behind it, and hides the scrollbar itself
26
+ * (a 15px grey trough under a 32px chip is chrome nobody asked for) — through
27
+ * a hoisted stylesheet, because `::-webkit-scrollbar` and `scrollbar-width`
28
+ * cannot be written as inline style.
29
+ *
30
+ * Hiding a scrollbar is only safe because nothing here is reachable ONLY by
31
+ * dragging it: every chip is a real `<button>`, so Tab walks the row and the
32
+ * browser scrolls the focused chip into view on its own.
33
+ *
34
+ * ## Every picker is a `SkinDialog`
35
+ *
36
+ * Which means every picker is a BOTTOM SHEET on a phone and a modal above it
37
+ * (owner ruling 2026-08-24, enforced by `stapel/no-bare-dialog`). The chips
38
+ * row is a phone surface, so in practice it is always the sheet — but the
39
+ * component does not decide that, the substrate does, and a tablet rendering
40
+ * the row gets the tablet answer without this file knowing.
41
+ *
42
+ * ## The library is not inventing a classified's filters
43
+ *
44
+ * A chip exists for each facet group the SERVER returned, each numeric range
45
+ * the CATEGORY SCHEMA declares, and the location — plus the leading
46
+ * "all filters" chip. Nothing here knows what a "brand" or a "mileage" is;
47
+ * a deployment with three facets gets three chips and a deployment with none
48
+ * gets the leading chip alone.
49
+ */
50
+ import { useState } from "react";
51
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
52
+ import { Button, Flex, Typography } from "antd";
53
+ import { SkinDialog, useDialogSurface } from "@stapel/tokens-antd/skin";
54
+ import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
55
+ import { radii, spacing } from "@stapel/tokens";
56
+ import type { FeatureDef } from "@stapel/attributes-react";
57
+ import { useFacetPanel } from "../headless/FacetPanel.js";
58
+ import { useAppliedCount } from "../headless/useAppliedCount.js";
59
+ import { useSearchState } from "../headless/SearchStateProvider.js";
60
+ import { buildRangeGroups } from "../state/ranges.js";
61
+ import type { FacetGroup } from "../state/facets.js";
62
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
63
+ import { FacetGroupControl } from "./FacetGroupControl.js";
64
+ import { RangeFilterRow } from "./RangeFilterRow.js";
65
+ import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
66
+
67
+ /** The class the scroller carries, for {@link chipRowCss}. */
68
+ export const CHIP_ROW_CLASS = "stapel-filter-chips";
69
+
70
+ /** The `href` the hoisted chip-row stylesheet is deduplicated by. */
71
+ export const CHIP_ROW_STYLE_HREF = "stapel-search-filter-chips";
72
+
73
+ /**
74
+ * The two rules an inline style cannot express: Firefox's `scrollbar-width`
75
+ * and WebKit's `::-webkit-scrollbar`. Static (no theme values), so one hoisted
76
+ * copy serves the document.
77
+ */
78
+ export function chipRowCss(): string {
79
+ return [
80
+ `.${CHIP_ROW_CLASS}{scrollbar-width:none;-ms-overflow-style:none}`,
81
+ `.${CHIP_ROW_CLASS}::-webkit-scrollbar{display:none}`,
82
+ ].join("");
83
+ }
84
+
85
+ /** The scroller: one line, its own overflow, nobody else's. */
86
+ const ROW: CSSProperties = {
87
+ display: "flex",
88
+ alignItems: "center",
89
+ gap: spacing[2],
90
+ flexWrap: "nowrap",
91
+ overflowX: "auto",
92
+ // A flick past the last chip must not hand the gesture to the page.
93
+ overscrollBehaviorInline: "contain",
94
+ // Room for the focus ring of the first and last chip, which a flush edge
95
+ // clips into invisibility.
96
+ paddingBlock: spacing[1],
97
+ };
98
+
99
+ const CHIP: CSSProperties = { flex: "0 0 auto", borderRadius: radii.full };
100
+
101
+ /** Which picker is open, if any. `null` closes everything. */
102
+ type OpenChip = string | null;
103
+
104
+ export interface FilterChipsProps {
105
+ /** The category's feature schema — the source of option labels, of which
106
+ * slugs get a range chip, and of how each group is drawn. */
107
+ readonly categoryFeatures?: readonly FeatureDef[];
108
+ readonly locale?: string;
109
+ /** The location control (`geo-react`), same slot the panel takes. Without
110
+ * it the location chip appears only when the URL already carries a point,
111
+ * so a shared link can still be widened. */
112
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
113
+ /** Open the whole panel — the leading chip's action. The page owns that
114
+ * sheet, because the page is the surface it covers. */
115
+ readonly onOpenAll: () => void;
116
+ }
117
+
118
+ /**
119
+ * The label a chip carries: the group's name alone when nothing is chosen,
120
+ * and the CHOICE when something is — "Brand" becomes "Bosch", "Brand, +2".
121
+ * A chip that reads "Brand" while filtering to Bosch is a lie the person can
122
+ * only catch by opening it.
123
+ */
124
+ function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknown>) => string): string {
125
+ const chosen = group.options.filter((option) => option.selected);
126
+ const first = chosen[0];
127
+ if (first === undefined) return group.label;
128
+ return chosen.length === 1
129
+ ? first.label
130
+ : `${first.label}${t(SEARCH_I18N_KEYS.filtersChipMore, { count: chosen.length - 1 })}`;
131
+ }
132
+
133
+ export function FilterChips(props: FilterChipsProps): ReactElement {
134
+ const t = useT();
135
+ const tPlural = useTPlural();
136
+ const { state, setGeo } = useSearchState();
137
+ const bag = useFacetPanel({
138
+ ...(props.categoryFeatures !== undefined
139
+ ? { categoryFeatures: props.categoryFeatures }
140
+ : {}),
141
+ ...(props.locale !== undefined ? { locale: props.locale } : {}),
142
+ });
143
+ const applied = useAppliedCount();
144
+ const surface = useDialogSurface();
145
+ const [open, setOpen] = useState<OpenChip>(null);
146
+
147
+ const ranges = buildRangeGroups({
148
+ state,
149
+ ...(props.categoryFeatures !== undefined
150
+ ? { categoryFeatures: props.categoryFeatures }
151
+ : {}),
152
+ t,
153
+ });
154
+ const groups =
155
+ bag.state.status === "ready"
156
+ ? bag.state.data.filter((group) => group.options.length > 0)
157
+ : [];
158
+
159
+ // The same sentence the panel's own commit button says, for the same reason:
160
+ // the results are BEHIND this sheet, so the button that closes it is the
161
+ // only place a person learns what their choice did.
162
+ const applyLabel =
163
+ applied.count === null || applied.kind === "unknown"
164
+ ? t(SEARCH_I18N_KEYS.filtersApply)
165
+ : tPlural(
166
+ applied.kind === "at_least"
167
+ ? SEARCH_I18N_KEYS.filtersShowCountAtLeast
168
+ : SEARCH_I18N_KEYS.filtersShowCount,
169
+ { count: applied.count }
170
+ );
171
+
172
+ const close = (): void => {
173
+ setOpen(null);
174
+ };
175
+
176
+ const sheetFor = (
177
+ id: string,
178
+ title: string,
179
+ body: ReactNode
180
+ ): ReactElement => (
181
+ <SkinDialog
182
+ open={open === id}
183
+ onClose={close}
184
+ title={title}
185
+ dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
186
+ data-testid={`filter-chip-sheet-${id}`}
187
+ footer={
188
+ <Button
189
+ block
190
+ type="primary"
191
+ data-testid={`filter-chip-apply-${id}`}
192
+ data-analytics="none"
193
+ data-analytics-reason="the filter is already applied; this closes the sheet"
194
+ onClick={close}
195
+ >
196
+ {applyLabel}
197
+ </Button>
198
+ }
199
+ >
200
+ {body}
201
+ </SkinDialog>
202
+ );
203
+
204
+ const geo = state.geo;
205
+ const showGeoChip = props.renderGeoFilter !== undefined || geo !== undefined;
206
+ const geoLabel =
207
+ geo === undefined
208
+ ? t(SEARCH_I18N_KEYS.geoTitle)
209
+ : geo.kind === "bbox"
210
+ ? t(SEARCH_I18N_KEYS.geoBox)
211
+ : t(SEARCH_I18N_KEYS.geoCenter, {
212
+ lat: geo.lat.toFixed(3),
213
+ lon: geo.lon.toFixed(3),
214
+ });
215
+
216
+ return (
217
+ <>
218
+ <style href={CHIP_ROW_STYLE_HREF} precedence="default">
219
+ {chipRowCss()}
220
+ </style>
221
+ <div
222
+ className={CHIP_ROW_CLASS}
223
+ style={ROW}
224
+ role="group"
225
+ aria-label={t(SEARCH_I18N_KEYS.filtersChipsLabel)}
226
+ data-testid="search-filter-chips"
227
+ data-surface={surface}
228
+ >
229
+ {/* The leading chip: the whole panel, in one icon-sized target. It is
230
+ icon-only because it is the one chip whose meaning does not change
231
+ — and it carries a DOT, not a count, because the counts are already
232
+ written on the chips beside it. */}
233
+ <Button
234
+ shape="circle"
235
+ aria-label={t(SEARCH_I18N_KEYS.filtersAll)}
236
+ icon={<SlidersGlyph />}
237
+ style={CHIP}
238
+ data-testid="search-filters-open"
239
+ data-active={bag.activeFilters > 0 ? "true" : "false"}
240
+ data-analytics="none"
241
+ data-analytics-reason="opening the filter sheet is a read, not a flow step"
242
+ onClick={props.onOpenAll}
243
+ >
244
+ {bag.activeFilters > 0 && <ActiveDot />}
245
+ </Button>
246
+
247
+ {showGeoChip && (
248
+ <Button
249
+ style={CHIP}
250
+ shape="round"
251
+ type={geo !== undefined ? "primary" : "default"}
252
+ data-testid="search-chip-geo"
253
+ data-analytics="none"
254
+ data-analytics-reason="a filter is a read, not a flow step"
255
+ onClick={() => {
256
+ setOpen("geo");
257
+ }}
258
+ >
259
+ {geoLabel}
260
+ </Button>
261
+ )}
262
+
263
+ {ranges.map((group) => (
264
+ <Button
265
+ key={group.slug}
266
+ style={CHIP}
267
+ shape="round"
268
+ type={group.active ? "primary" : "default"}
269
+ data-testid={`search-chip-range-${group.slug}`}
270
+ data-analytics="none"
271
+ data-analytics-reason="a filter is a read, not a flow step"
272
+ onClick={() => {
273
+ setOpen(`range:${group.slug}`);
274
+ }}
275
+ >
276
+ {group.label}
277
+ </Button>
278
+ ))}
279
+
280
+ {groups.map((group) => (
281
+ <Button
282
+ key={group.slug}
283
+ style={CHIP}
284
+ shape="round"
285
+ type={group.selected.length > 0 ? "primary" : "default"}
286
+ data-testid={`search-chip-${group.slug}`}
287
+ data-analytics="none"
288
+ data-analytics-reason="a filter is a read, not a flow step"
289
+ onClick={() => {
290
+ setOpen(`facet:${group.slug}`);
291
+ }}
292
+ >
293
+ {chipLabel(group, t)}
294
+ </Button>
295
+ ))}
296
+ </div>
297
+
298
+ {/* One sheet per chip, rendered only for the open one: a dozen mounted
299
+ dialogs is a dozen focus traps waiting for a stray `open`. */}
300
+ {open?.startsWith("facet:") === true &&
301
+ (() => {
302
+ const slug = open.slice("facet:".length);
303
+ const group = groups.find((candidate) => candidate.slug === slug);
304
+ return group === undefined
305
+ ? null
306
+ : sheetFor(
307
+ open,
308
+ group.label,
309
+ <FacetGroupControl
310
+ group={group}
311
+ onToggle={bag.toggle}
312
+ heading={false}
313
+ // A sheet devoted to ONE group has the room for all of it;
314
+ // "Show all" inside it would be a fold inside a fold.
315
+ visibleOptions={null}
316
+ />
317
+ );
318
+ })()}
319
+
320
+ {open?.startsWith("range:") === true &&
321
+ (() => {
322
+ const slug = open.slice("range:".length);
323
+ const group = ranges.find((candidate) => candidate.slug === slug);
324
+ return group === undefined
325
+ ? null
326
+ : sheetFor(
327
+ open,
328
+ group.label,
329
+ <RangeFilterRow group={group} onApply={bag.setRange} />
330
+ );
331
+ })()}
332
+
333
+ {open === "geo" &&
334
+ sheetFor(
335
+ "geo",
336
+ t(SEARCH_I18N_KEYS.geoTitle),
337
+ <Flex vertical gap={spacing[3]}>
338
+ {/* SETTING a centre needs a geocoder, which is `geo-react`'s and
339
+ the deployment's. ADJUSTING or CLEARING one the URL already
340
+ carries does not — which is why the chip still opens on a
341
+ shared link with no slot filled, and why the absence of the
342
+ slot is NAMED in development rather than left as a gap under
343
+ the sheet's title. */}
344
+ {props.renderGeoFilter?.({
345
+ value: geo,
346
+ onChange: (next) => {
347
+ setGeo(next);
348
+ },
349
+ }) ?? <SlotPlaceholder name="renderGeoFilter" data-testid="search-chip-geo-slot" />}
350
+ {geo !== undefined && (
351
+ <>
352
+ <Typography.Text type="secondary" data-testid="search-chip-geo-summary">
353
+ {geoLabel}
354
+ </Typography.Text>
355
+ <Button
356
+ style={{ alignSelf: "flex-start" }}
357
+ data-testid="search-chip-geo-clear"
358
+ data-analytics="none"
359
+ data-analytics-reason="a filter is a read, not a flow step"
360
+ onClick={() => {
361
+ setGeo(null);
362
+ }}
363
+ >
364
+ {t(SEARCH_I18N_KEYS.geoClear)}
365
+ </Button>
366
+ </>
367
+ )}
368
+ </Flex>
369
+ )}
370
+ </>
371
+ );
372
+ }
373
+
374
+ /** The mark on the "all filters" chip: something is applied. Not a count —
375
+ * the counts are on the chips beside it, and a number inside a 32px circle is
376
+ * a number nobody reads. */
377
+ function ActiveDot(): ReactElement {
378
+ return (
379
+ <span
380
+ aria-hidden="true"
381
+ data-testid="search-filters-dot"
382
+ style={{
383
+ position: "absolute",
384
+ insetInlineEnd: spacing[1],
385
+ insetBlockStart: spacing[1],
386
+ width: spacing[2],
387
+ height: spacing[2],
388
+ borderRadius: radii.full,
389
+ background: "currentColor",
390
+ }}
391
+ />
392
+ );
393
+ }
394
+
395
+ /** Sliders — the one glyph a filter control is universally drawn with. Inline
396
+ * SVG, like every other glyph in the fleet's skins: no icon-font dependency. */
397
+ function SlidersGlyph(): ReactElement {
398
+ return (
399
+ <svg
400
+ width="18"
401
+ height="18"
402
+ viewBox="0 0 24 24"
403
+ fill="none"
404
+ stroke="currentColor"
405
+ strokeWidth="1.75"
406
+ strokeLinecap="round"
407
+ role="img"
408
+ aria-hidden="true"
409
+ >
410
+ <path d="M4 7h10M18 7h2M4 17h4M12 17h8" />
411
+ <circle cx="16" cy="7" r="2.2" />
412
+ <circle cx="10" cy="17" r="2.2" />
413
+ </svg>
414
+ );
415
+ }