@stapel/search-react 0.41.1 → 0.42.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.
@@ -122,6 +122,42 @@ export interface ViewSwitchProps {
122
122
  readonly views: readonly SearchView[];
123
123
  readonly value: string;
124
124
  readonly onChange: (id: string) => void;
125
+ /**
126
+ * THE GLYPHS ALONE — the phone form, and it is a different control rather
127
+ * than the same one at a smaller type step.
128
+ *
129
+ * Named + glyph is two labels wide, and on a 390px toolbar it shares a line
130
+ * with the sort select. The select has a floor (the width of its own longest
131
+ * option — see `SortSelect`), so the switch is what gave: it was CUT by the
132
+ * select's leading edge mid-word, and the last arrangement on offer read as
133
+ * a truncated word with no indication there was a control there at all. A
134
+ * segmented control cut in half is worse than one with no words: the first
135
+ * looks broken, the second looks deliberate.
136
+ *
137
+ * So below the pane's phone breakpoint each option is its glyph and nothing
138
+ * else, and the NAME moves to where a name belongs — `aria-label`, off the
139
+ * same i18n key the wide form prints, so nothing is lost to a screen reader
140
+ * or to a locale. A view with no `icon` keeps its name: an option drawn as
141
+ * an empty box is not a smaller control, it is an unreachable one.
142
+ */
143
+ readonly compact?: boolean;
144
+ }
145
+
146
+ /**
147
+ * One option's face. Compact, that is the glyph carrying the view's NAME:
148
+ * `role="img"` is what makes an `aria-label` computable here — the glyph
149
+ * inside is `aria-hidden`, and a bare `<span>` has no role for a name to
150
+ * attach to, so the radio the browser builds around it would be nameless.
151
+ */
152
+ function ViewOptionLabel(props: {
153
+ readonly name: string;
154
+ readonly icon: ReactNode;
155
+ }): ReactElement {
156
+ return (
157
+ <span role="img" aria-label={props.name} data-testid="search-view-option-icon">
158
+ {props.icon}
159
+ </span>
160
+ );
125
161
  }
126
162
 
127
163
  /**
@@ -131,17 +167,29 @@ export interface ViewSwitchProps {
131
167
  export function ViewSwitch(props: ViewSwitchProps): ReactElement | null {
132
168
  const t = useT();
133
169
  if (props.views.length < 2) return null;
170
+ const compact = props.compact === true;
134
171
  return (
135
172
  <Segmented<string>
136
173
  aria-label={t(SEARCH_I18N_KEYS.viewLabel)}
137
174
  value={props.value}
138
175
  data-testid="search-view-switch"
176
+ data-view-switch={compact ? "compact" : "named"}
139
177
  onChange={props.onChange}
140
- options={props.views.map((view) => ({
141
- value: view.id,
142
- label: t(view.labelKey),
143
- ...(view.icon !== undefined ? { icon: view.icon } : {}),
144
- }))}
178
+ // It keeps the width its glyphs need and no more: the group it sits in
179
+ // wraps as a unit when the line is short, and a switch that shrank
180
+ // instead would be the cut control this form exists to end.
181
+ style={{ flex: "0 0 auto" }}
182
+ options={props.views.map((view) => {
183
+ const name = t(view.labelKey);
184
+ if (compact && view.icon !== undefined) {
185
+ return { value: view.id, label: <ViewOptionLabel name={name} icon={view.icon} /> };
186
+ }
187
+ return {
188
+ value: view.id,
189
+ label: name,
190
+ ...(view.icon !== undefined ? { icon: view.icon } : {}),
191
+ };
192
+ })}
145
193
  />
146
194
  );
147
195
  }
@@ -53,6 +53,7 @@ export {
53
53
  railScrollbarCss,
54
54
  railStyle,
55
55
  } from "./SearchPage.js";
56
+ export { RAIL_OFFSET_PROPERTY, useRailFits } from "./railFit.js";
56
57
  export type {
57
58
  SearchPageProps,
58
59
  SearchFiltersLayout,
@@ -61,6 +62,7 @@ export type {
61
62
  SearchFiltersOpenReason,
62
63
  SearchBlockRhythm,
63
64
  SearchRailFrom,
65
+ SearchRailScroll,
64
66
  SearchRailScrollbar,
65
67
  } from "./SearchPage.js";
66
68
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * DOES THE FILTER RAIL FIT UNDER THE HOST'S HEADER?
3
+ *
4
+ * The question exists only for `railScroll="page"` — the arm where the rail is
5
+ * NOT a scroll container of its own and the page is the only thing that
6
+ * scrolls. There a rail taller than the window cannot be sticky: a stuck box
7
+ * is cut off at the foot of the screen and its last controls become
8
+ * unreachable, because the scroll that would reveal them is the page's and the
9
+ * page is not moving the rail. So the rail sticks while it fits and stands in
10
+ * flow while it does not, and "fits" is a measurement, not a breakpoint:
11
+ * the same catalogue draws four facet groups on one leaf and twenty on the
12
+ * next.
13
+ *
14
+ * Two numbers, and both are read from the browser rather than restated:
15
+ *
16
+ * - the rail's own height — `ResizeObserver`, because the rail changes height
17
+ * without the window changing at all (a facet group unfolds, an answer
18
+ * lands with more values, the schema arrives and reorders the panel);
19
+ * - the room under the host's chrome — `window.innerHeight` minus the offset
20
+ * the rail already carries as `scroll-margin-top`. That property is the
21
+ * same offset said in the one property that MEANS it (the header covers
22
+ * that much of the top of the scrollport), and the engine resolves it to
23
+ * pixels — a `var()`, a `calc()` or a `rem` included — so the fit test
24
+ * needs no CSS parser of its own to read what a host wrote.
25
+ *
26
+ * The house rule from `useElementWidth` holds here too: **zero is not a
27
+ * measurement**. A detached or `display: none` rail reports 0 and would
28
+ * otherwise be declared to fit forever. And the honest answer before the first
29
+ * measurement is "no": a static rail scrolls with the page under every
30
+ * circumstance, so an unmeasured frame degrades to the arm that cannot hide a
31
+ * control.
32
+ */
33
+ import { useEffect, useState } from "react";
34
+ import type { RefObject } from "react";
35
+
36
+ /** The CSS property the rail's top offset is carried in — see the note above.
37
+ * Exported so a test can assert the two halves read and write the same one. */
38
+ export const RAIL_OFFSET_PROPERTY = "scrollMarginTop";
39
+
40
+ /**
41
+ * Measure whether `ref`'s element is short enough to stand under the chrome
42
+ * its own `scroll-margin-top` names.
43
+ *
44
+ * `enabled` is the `railScroll === "page"` arm: under `"internal"` nothing is
45
+ * measured and nothing is listened to, so the default arm carries no observer
46
+ * and no listener at all.
47
+ */
48
+ export function useRailFits(
49
+ ref: RefObject<HTMLElement | null>,
50
+ enabled: boolean
51
+ ): boolean {
52
+ const [fits, setFits] = useState(false);
53
+
54
+ useEffect(() => {
55
+ if (!enabled) {
56
+ // Leaving the arm resets the answer: a rail that stops being measured
57
+ // must not keep the last measurement's `position: sticky`.
58
+ setFits(false);
59
+ return undefined;
60
+ }
61
+ const element = ref.current;
62
+ if (element === null) return undefined;
63
+
64
+ const measure = (): void => {
65
+ const height = element.getBoundingClientRect().height;
66
+ // Zero is not a measurement — see the note above.
67
+ if (height <= 0) return;
68
+ const offset = Number.parseFloat(
69
+ window.getComputedStyle(element)[RAIL_OFFSET_PROPERTY]
70
+ );
71
+ const room = window.innerHeight - (Number.isFinite(offset) ? offset : 0);
72
+ const next = height <= room;
73
+ setFits((previous) => (previous === next ? previous : next));
74
+ };
75
+
76
+ measure();
77
+ // The window's own height is not the element's: a rotation, a devtools
78
+ // pane or a resized window changes the room without changing the rail.
79
+ window.addEventListener("resize", measure);
80
+ const observer =
81
+ typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
82
+ observer?.observe(element);
83
+ return () => {
84
+ window.removeEventListener("resize", measure);
85
+ observer?.disconnect();
86
+ };
87
+ }, [ref, enabled]);
88
+
89
+ return fits;
90
+ }