@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.
- package/CHANGELOG.md +63 -0
- package/README.md +13 -0
- package/dist/default/SearchPage.d.ts +50 -5
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +79 -12
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +9 -1
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/ViewSwitch.d.ts +19 -0
- package/dist/default/ViewSwitch.d.ts.map +1 -1
- package/dist/default/ViewSwitch.js +25 -5
- package/dist/default/ViewSwitch.js.map +1 -1
- package/dist/default/index.d.ts +2 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/default/railFit.d.ts +14 -0
- package/dist/default/railFit.d.ts.map +1 -0
- package/dist/default/railFit.js +80 -0
- package/dist/default/railFit.js.map +1 -0
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/SearchPage.tsx +136 -17
- package/src/default/SortSelect.tsx +9 -1
- package/src/default/ViewSwitch.tsx +53 -5
- package/src/default/index.ts +2 -0
- package/src/default/railFit.ts +90 -0
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
}
|
package/src/default/index.ts
CHANGED
|
@@ -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
|
+
}
|