@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.1
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/components/_shared/use-reduced-motion.jsx +49 -0
- package/components/button/button.jsx +512 -0
- package/components/icon/action-icon.jsx +155 -0
- package/components/icon/display-icon.jsx +137 -0
- package/components/icon/featured-icon.jsx +162 -0
- package/components/icon/icon.jsx +154 -0
- package/components/module-icon/module-icon.jsx +387 -0
- package/components/nav-shell/nav-shell.jsx +252 -0
- package/components/org-switcher/org-switcher.jsx +604 -0
- package/components/page-template/page-template.jsx +147 -0
- package/components/scrollbar/scrollbar.jsx +232 -0
- package/components/search/search.jsx +689 -0
- package/components/sidenav/sidenav.jsx +951 -0
- package/components/top-nav/top-nav.jsx +1003 -0
- package/package.json +27 -4
- package/README.md +0 -3
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search, Pathway Design System (v1, search bar only)
|
|
3
|
+
*
|
|
4
|
+
* Two named exports:
|
|
5
|
+
* SearchInput: the base pill-shaped search bar
|
|
6
|
+
* TopNavSearch: the nav bar wrapper (collapsed icon button + spring-expand bar)
|
|
7
|
+
*
|
|
8
|
+
* Spec: components/search/search-spec.md
|
|
9
|
+
* Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40007095-4048
|
|
10
|
+
* TopNav.Search, a COMPONENT_SET: Platform=Desktop x
|
|
11
|
+
* State=Collapsed|Expanded|Open. The id cited here until 2026-10-07,
|
|
12
|
+
* 40006978-23158, reads as a 2594x1680 FRAME named "Frame 633379":
|
|
13
|
+
* a layout frame, not a component. A genuinely different general
|
|
14
|
+
* search field exists at 40006978-23446, which this repo does NOT
|
|
15
|
+
* implement.
|
|
16
|
+
*
|
|
17
|
+
* v1 scope: search bar + TopNavSearch collapsed/expanded states only.
|
|
18
|
+
* The Open state (results dropdown) is deferred, see spec §17.
|
|
19
|
+
*
|
|
20
|
+
* Figma nodes read before this file was generated:
|
|
21
|
+
* SearchInput (all states): 40006978-23158
|
|
22
|
+
* FilterSelected state: 40007351-13533
|
|
23
|
+
* TopNavSearch: 40007095-4048
|
|
24
|
+
*
|
|
25
|
+
* Icon ligature names confirmed from Figma data-name attributes:
|
|
26
|
+
* search, cancel, filter_alt
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
30
|
+
import { Icon } from "../icon/icon.jsx";
|
|
31
|
+
|
|
32
|
+
// ─── DESIGN TOKENS ─────────────────────────────────────────────────────────────
|
|
33
|
+
// Every value is a semantic CSS variable. Fallbacks are the resolved values from
|
|
34
|
+
// the token contract, used only when Storybook has not loaded it yet.
|
|
35
|
+
export const T = {
|
|
36
|
+
// THIS IS THE GLOBAL SEARCH, AND IT IS A TOP NAV COMPONENT.
|
|
37
|
+
//
|
|
38
|
+
// There is no separate "generic search field" in this system. The field below
|
|
39
|
+
// and the control in the nav are one component with one set of tokens and one
|
|
40
|
+
// behaviour; the field is simply the part of it that is visible once it is
|
|
41
|
+
// expanded. It is scoped to the top nav because that is the only place it
|
|
42
|
+
// appears today. Treating it as a general-purpose search is what produced a
|
|
43
|
+
// Storybook page showing a mix of half-finished field variants out of
|
|
44
|
+
// context. Reported 2026-10-02.
|
|
45
|
+
//
|
|
46
|
+
// The FIELD's own values come from Figma node 40007095:4048:
|
|
47
|
+
// State=Expanded fill #ffffff, stroke #f9f7f5
|
|
48
|
+
// State=Open fill #ffffff, stroke #4b6ec3, plus the results menu
|
|
49
|
+
// White in both modes on purpose: the bar it sits on is the brand chrome in
|
|
50
|
+
// both modes, so the field does not invert with the theme. It was
|
|
51
|
+
// Fill/Static/Neutral/Faint with Action/Primary/Strong borders, a light-page
|
|
52
|
+
// field dropped onto brand chrome.
|
|
53
|
+
barBg: "var(--semantic-color-fill-static-neutral-mono)",
|
|
54
|
+
filterActiveFill:"var(--semantic-color-fill-action-primary-subtle-rest)",
|
|
55
|
+
// Icon pill hover/pressed: no semantic token resolves to the correct subtle overlay
|
|
56
|
+
// on a white surface. fill.action.secondary.hover = warm-neutral-200 (#f7f5f3)
|
|
57
|
+
// which creates a visible warm cream box, wrong. Using direct rgba values as a
|
|
58
|
+
// token gap (see search-spec.md §17).
|
|
59
|
+
iconPillHover: "var(--semantic-color-fill-action-secondary-hover)",
|
|
60
|
+
iconPillPressed: "var(--semantic-color-fill-action-secondary-pressed)",
|
|
61
|
+
badgeFill: "var(--semantic-color-fill-action-primary-strong-rest)",
|
|
62
|
+
disabledBg: "var(--primitive-color-cool-neutral-10)", // token gap §17
|
|
63
|
+
|
|
64
|
+
// Bar borders
|
|
65
|
+
borderIdle: "var(--semantic-color-stroke-static-neutral-faint)", // #f9f7f5, State=Expanded
|
|
66
|
+
borderHover: "var(--semantic-color-stroke-action-primary-strong-hover)",
|
|
67
|
+
borderActive: "var(--semantic-color-stroke-action-primary-strong-pressed)", // #4b6ec3, State=Open
|
|
68
|
+
borderError: "var(--semantic-color-stroke-action-status-negative-rest)",
|
|
69
|
+
borderDisabled: "var(--primitive-color-cool-neutral-25)", // token gap §17
|
|
70
|
+
divider: "var(--semantic-color-stroke-action-secondary-rest)",
|
|
71
|
+
|
|
72
|
+
// Text
|
|
73
|
+
textPlaceholder:"var(--semantic-color-foreground-static-neutral-base)",
|
|
74
|
+
textValue: "var(--semantic-color-foreground-static-neutral-bold)",
|
|
75
|
+
|
|
76
|
+
// Icons
|
|
77
|
+
iconIdle: "var(--semantic-color-foreground-action-secondary-rest)",
|
|
78
|
+
iconHover: "var(--semantic-color-foreground-action-secondary-hover)",
|
|
79
|
+
iconDisabled:"var(--semantic-color-foreground-action-disabled)",
|
|
80
|
+
iconError: "var(--semantic-color-foreground-action-status-negative-on-subtle-rest)",
|
|
81
|
+
|
|
82
|
+
// TOPNAV.SEARCH: its own control, not a generic field on a nav.
|
|
83
|
+
//
|
|
84
|
+
// It sits on the brand bar, so every part of the collapsed state is
|
|
85
|
+
// Action/Ghost. Measured against Figma node 40007095:4048, Platform=Desktop:
|
|
86
|
+
// State=Collapsed Container.Icon fill #b6c6ec @8%, stroke #b6c6ec @36%
|
|
87
|
+
// State=Expanded field fill #ffffff, stroke #f9f7f5
|
|
88
|
+
// State=Open field fill #ffffff, stroke #4b6ec3, + results menu
|
|
89
|
+
//
|
|
90
|
+
// This was Action/Primary/Subtle for the fill, Action/Primary/Strong for the
|
|
91
|
+
// border and Foreground/Static/Neutral/MONO for the glyph. Mono is an
|
|
92
|
+
// inversion anchor (#ffffff Light, #181b2b Midnight), so the magnifier went
|
|
93
|
+
// near-black on the navy bar in Midnight. Reported 2026-10-02.
|
|
94
|
+
navIconFill: "var(--semantic-color-foreground-action-ghost-rest)",
|
|
95
|
+
navIconHover: "var(--semantic-color-foreground-action-ghost-hover)",
|
|
96
|
+
collapsedBtnFill: "var(--semantic-color-fill-action-ghost-rest)",
|
|
97
|
+
collapsedBtnHover: "var(--semantic-color-fill-action-ghost-hover)",
|
|
98
|
+
collapsedBtnPressed: "var(--semantic-color-fill-action-ghost-pressed)",
|
|
99
|
+
collapsedBtnBorder: "var(--semantic-color-stroke-action-ghost-rest)",
|
|
100
|
+
collapsedBtnBorderHov:"var(--semantic-color-stroke-action-ghost-hover)",
|
|
101
|
+
// Expanded field. White in both modes on purpose: the bar it sits on is the
|
|
102
|
+
// brand chrome in both modes, so the field does not invert with the theme.
|
|
103
|
+
navFieldFill: "var(--semantic-color-fill-static-neutral-mono)",
|
|
104
|
+
navFieldBorder: "var(--semantic-color-stroke-static-neutral-faint)",
|
|
105
|
+
navFieldBorderOpen: "var(--semantic-color-stroke-action-primary-strong-pressed)",
|
|
106
|
+
// Results menu under the expanded field: an elevated popover, so it DOES
|
|
107
|
+
// invert with the theme.
|
|
108
|
+
navMenuFill: "var(--semantic-color-fill-surface-elevated)",
|
|
109
|
+
navMenuBorder: "var(--semantic-color-stroke-static-neutral-faint)",
|
|
110
|
+
badgeBorderColor: "var(--semantic-color-fill-static-neutral-faint)",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ─── LAYOUT VALUES ─────────────────────────────────────────────────────────────
|
|
114
|
+
export const L = {
|
|
115
|
+
touchTarget: 48,
|
|
116
|
+
// The expanded bar's exit control. AA touch target rather than the nav's own
|
|
117
|
+
// 48, because it sits inside the bar's own run and 48 would crowd the field.
|
|
118
|
+
exitTarget: "var(--semantic-layout-units-accessibility-touch-target-aa-width)",
|
|
119
|
+
exitGlyph: 20,
|
|
120
|
+
pillHeight: 36,
|
|
121
|
+
barRadius: 64, // cornerradius.full
|
|
122
|
+
iconBtnRadius: 12, // cornerradius.focused-element
|
|
123
|
+
iconBtnSize: 24,
|
|
124
|
+
iconSize: 16,
|
|
125
|
+
// 16, not 20. Figma TopNav.Search Container.Icon is a 32x32 box holding a
|
|
126
|
+
// 16x16 search instance, read off the leaf on 2026-10-02.
|
|
127
|
+
iconSizeCollapsed: 16,
|
|
128
|
+
barPadH: 8, // layout.units.padding.xtight
|
|
129
|
+
barGap: 8, // layout.units.gap.xtight
|
|
130
|
+
iconPillPad: 4, // layout.units.padding.xxtight
|
|
131
|
+
dividerPad: 4, // layout.units.padding.xxtight
|
|
132
|
+
badgeSize: 6,
|
|
133
|
+
expandedWidth: 320, // SearchInput width inside TopNavSearch
|
|
134
|
+
containerPad: 4, // TopNavSearch container padding
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ─── TYPOGRAPHY ────────────────────────────────────────────────────────────────
|
|
138
|
+
const TYPE_INPUT = {
|
|
139
|
+
fontFamily: "'Red Hat Text', sans-serif",
|
|
140
|
+
fontSize: "var(--semantic-type-font-size-s)",
|
|
141
|
+
fontWeight: 400,
|
|
142
|
+
lineHeight: "var(--semantic-type-line-height-s-single)",
|
|
143
|
+
letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
144
|
+
WebkitFontSmoothing: "antialiased",
|
|
145
|
+
MozOsxFontSmoothing: "grayscale",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ─── ICON HELPER ───────────────────────────────────────────────────────────────
|
|
149
|
+
// Material Symbols Rounded. Ligature name read from Figma data-name attribute.
|
|
150
|
+
// Font loaded by Storybook preview-head.html and by the standalone HTML demo.
|
|
151
|
+
/* Icon is imported, not redeclared. The copy that used to live here baked in
|
|
152
|
+
FILL=1, which is correct for this component and WRONG as a default: FILL is
|
|
153
|
+
read from Figma per component and the TopNav search bar happens to use the
|
|
154
|
+
filled variant. Every call site here passes fill={1} so the axis is visible
|
|
155
|
+
at the point of use rather than hidden in a local helper. */
|
|
156
|
+
|
|
157
|
+
// ─── ICON PILL BUTTON ──────────────────────────────────────────────────────────
|
|
158
|
+
// 24×24px button with 64px-radius icon pill inside. Hover/pressed via state.
|
|
159
|
+
function IconPillButton({ iconName, iconSize = L.iconSize, iconColor, label, onClick, style }) {
|
|
160
|
+
const [hov, setHov] = useState(false);
|
|
161
|
+
const [pressed, setPressed] = useState(false);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
aria-label={label}
|
|
167
|
+
onClick={onClick}
|
|
168
|
+
onMouseEnter={() => setHov(true)}
|
|
169
|
+
onMouseLeave={() => { setHov(false); setPressed(false); }}
|
|
170
|
+
onMouseDown={() => setPressed(true)}
|
|
171
|
+
onMouseUp={() => setPressed(false)}
|
|
172
|
+
style={{
|
|
173
|
+
display: "flex",
|
|
174
|
+
alignItems: "center",
|
|
175
|
+
justifyContent: "center",
|
|
176
|
+
width: L.iconBtnSize,
|
|
177
|
+
height: L.iconBtnSize,
|
|
178
|
+
borderRadius: L.iconBtnRadius,
|
|
179
|
+
background: "transparent",
|
|
180
|
+
border: "none",
|
|
181
|
+
cursor: "pointer",
|
|
182
|
+
padding: 0,
|
|
183
|
+
flexShrink: 0,
|
|
184
|
+
...style,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{/* Icon pill */}
|
|
188
|
+
<div
|
|
189
|
+
style={{
|
|
190
|
+
display: "flex",
|
|
191
|
+
alignItems: "center",
|
|
192
|
+
justifyContent: "center",
|
|
193
|
+
width: "100%",
|
|
194
|
+
height: "100%",
|
|
195
|
+
borderRadius: L.barRadius,
|
|
196
|
+
padding: L.iconPillPad,
|
|
197
|
+
background: pressed ? T.iconPillPressed : hov ? T.iconPillHover : "transparent",
|
|
198
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
<Icon name={iconName} size={iconSize} color={iconColor} fill={1} />
|
|
202
|
+
</div>
|
|
203
|
+
</button>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
208
|
+
// SEARCH INPUT
|
|
209
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
210
|
+
/**
|
|
211
|
+
* SearchInput: the base pill-shaped search bar.
|
|
212
|
+
*
|
|
213
|
+
* Props:
|
|
214
|
+
* value, controlled string value
|
|
215
|
+
* placeholder, placeholder text (default "Search...")
|
|
216
|
+
* showFilter, renders the trailing filter button + divider
|
|
217
|
+
* filterActive, filter-active visual state (active border, highlighted funnel)
|
|
218
|
+
* filterBadge, shows the dot badge on the funnel icon
|
|
219
|
+
* disabled, disabled state (38% opacity, non-interactive)
|
|
220
|
+
* error: error visual state
|
|
221
|
+
* onSearch, (value) => void, fired on Enter or search icon click
|
|
222
|
+
* onFilterClick, () => void, fired on filter button click
|
|
223
|
+
* onChange, (value) => void, fired on every keystroke
|
|
224
|
+
* onClear, () => void, fired when clear button clicked
|
|
225
|
+
* className, additional class on root element
|
|
226
|
+
* id: for label association
|
|
227
|
+
* searchIconAriaLabel, override the leading search icon button label
|
|
228
|
+
* onSearchIconClick: when provided, REPLACES the default onSearch behaviour of the
|
|
229
|
+
* leading search icon button.
|
|
230
|
+
* DEPRECATED for the expand/collapse job: the expanded bar and
|
|
231
|
+
* TopNav's takeover both exit via a back arrow placed OUTSIDE
|
|
232
|
+
* the field, to the left, so the leading glyph is never a second
|
|
233
|
+
* exit sitting beside the trailing clear ✕. Still available for
|
|
234
|
+
* a genuinely different leading action.
|
|
235
|
+
*/
|
|
236
|
+
export function SearchInput({
|
|
237
|
+
value = "",
|
|
238
|
+
placeholder = "Search...",
|
|
239
|
+
showFilter = false,
|
|
240
|
+
filterActive = false,
|
|
241
|
+
filterBadge = false,
|
|
242
|
+
disabled = false,
|
|
243
|
+
error = false,
|
|
244
|
+
onSearch,
|
|
245
|
+
onFilterClick,
|
|
246
|
+
onChange,
|
|
247
|
+
onClear,
|
|
248
|
+
className = "",
|
|
249
|
+
id,
|
|
250
|
+
searchIconAriaLabel = "Search",
|
|
251
|
+
onSearchIconClick, // when set, overrides the icon button's onClick
|
|
252
|
+
inputRef: externalInputRef, // optional external ref (TopNav.Search uses this to focus on expand)
|
|
253
|
+
}) {
|
|
254
|
+
const [hovered, setHovered] = useState(false);
|
|
255
|
+
const [focused, setFocused] = useState(false);
|
|
256
|
+
const internalInputRef = useRef(null);
|
|
257
|
+
const inputRef = externalInputRef || internalInputRef;
|
|
258
|
+
|
|
259
|
+
// Derived state
|
|
260
|
+
const hasValue = value.length > 0;
|
|
261
|
+
const isDisabled = disabled;
|
|
262
|
+
const isError = error && !disabled;
|
|
263
|
+
|
|
264
|
+
// Bar border resolution per spec §6 state matrix
|
|
265
|
+
const getBorderStyle = () => {
|
|
266
|
+
if (isDisabled) return { border: `1px solid ${T.borderDisabled}` };
|
|
267
|
+
if (isError) return { border: `1px solid ${T.borderError}` };
|
|
268
|
+
if (filterActive || focused || hasValue)
|
|
269
|
+
return { border: `1px solid ${T.borderActive}` };
|
|
270
|
+
if (hovered) return { border: `1px solid ${T.borderHover}` };
|
|
271
|
+
return { border: `0.75px solid ${T.borderIdle}` };
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Icon colour resolution
|
|
275
|
+
const iconColor = (() => {
|
|
276
|
+
if (isDisabled) return T.iconDisabled;
|
|
277
|
+
if (isError) return T.iconError;
|
|
278
|
+
if (hovered || focused) return T.iconHover;
|
|
279
|
+
return T.iconIdle;
|
|
280
|
+
})();
|
|
281
|
+
|
|
282
|
+
const handleKeyDown = (e) => {
|
|
283
|
+
if (e.key === "Enter") onSearch?.(value);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const handleClear = () => {
|
|
287
|
+
onClear?.();
|
|
288
|
+
inputRef.current?.focus();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
// Touch target, 48px, full width (or 320px when inside TopNavSearch)
|
|
293
|
+
<div
|
|
294
|
+
className={className}
|
|
295
|
+
style={{
|
|
296
|
+
boxSizing: "border-box",
|
|
297
|
+
minHeight: L.touchTarget,
|
|
298
|
+
display: "flex",
|
|
299
|
+
flexDirection: "column",
|
|
300
|
+
justifyContent: "center",
|
|
301
|
+
width: "100%",
|
|
302
|
+
opacity: isDisabled ? 0.38 : 1,
|
|
303
|
+
pointerEvents: isDisabled ? "none" : "auto",
|
|
304
|
+
}}
|
|
305
|
+
onMouseEnter={() => !isDisabled && setHovered(true)}
|
|
306
|
+
onMouseLeave={() => setHovered(false)}
|
|
307
|
+
>
|
|
308
|
+
{/* Pill, Container.Main */}
|
|
309
|
+
<div
|
|
310
|
+
role="search"
|
|
311
|
+
aria-label="Search"
|
|
312
|
+
style={{
|
|
313
|
+
boxSizing: "border-box",
|
|
314
|
+
display: "flex",
|
|
315
|
+
alignItems: "center",
|
|
316
|
+
gap: L.barGap,
|
|
317
|
+
minHeight: L.pillHeight,
|
|
318
|
+
borderRadius: L.barRadius,
|
|
319
|
+
background: isDisabled ? T.disabledBg : T.barBg,
|
|
320
|
+
overflow: "hidden",
|
|
321
|
+
paddingLeft: L.barPadH,
|
|
322
|
+
paddingRight: L.barPadH,
|
|
323
|
+
transition: "border-color var(--motion-duration-2) var(--motion-easing-standard), background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
324
|
+
...getBorderStyle(),
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{/* Leading icon, search button */}
|
|
328
|
+
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
|
329
|
+
<IconPillButton
|
|
330
|
+
iconName="search"
|
|
331
|
+
iconColor={iconColor}
|
|
332
|
+
label={searchIconAriaLabel}
|
|
333
|
+
onClick={onSearchIconClick ? onSearchIconClick : () => onSearch?.(value)}
|
|
334
|
+
/>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
{/* Text input */}
|
|
338
|
+
<input
|
|
339
|
+
ref={inputRef}
|
|
340
|
+
id={id}
|
|
341
|
+
type="text"
|
|
342
|
+
role="searchbox"
|
|
343
|
+
value={value}
|
|
344
|
+
placeholder={placeholder}
|
|
345
|
+
disabled={isDisabled}
|
|
346
|
+
autoComplete="off"
|
|
347
|
+
aria-label="Search"
|
|
348
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
349
|
+
onFocus={() => setFocused(true)}
|
|
350
|
+
onBlur={() => setFocused(false)}
|
|
351
|
+
onKeyDown={handleKeyDown}
|
|
352
|
+
style={{
|
|
353
|
+
...TYPE_INPUT,
|
|
354
|
+
flex: 1,
|
|
355
|
+
minWidth: 0,
|
|
356
|
+
background: "transparent",
|
|
357
|
+
border: "none",
|
|
358
|
+
outline: "none",
|
|
359
|
+
color: hasValue ? T.textValue : T.textPlaceholder,
|
|
360
|
+
}}
|
|
361
|
+
/>
|
|
362
|
+
|
|
363
|
+
{/* Trailing slot, cancel button (conditional) */}
|
|
364
|
+
{hasValue && (
|
|
365
|
+
<IconPillButton
|
|
366
|
+
iconName="cancel"
|
|
367
|
+
iconColor={iconColor}
|
|
368
|
+
label="Clear search"
|
|
369
|
+
onClick={handleClear}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{/* Trailing slot, filter wrap + badge (conditional) */}
|
|
374
|
+
{showFilter && (
|
|
375
|
+
<div
|
|
376
|
+
style={{
|
|
377
|
+
display: "flex",
|
|
378
|
+
alignItems: "center",
|
|
379
|
+
flexShrink: 0,
|
|
380
|
+
borderLeft: `0.75px solid ${T.divider}`,
|
|
381
|
+
paddingLeft: L.dividerPad,
|
|
382
|
+
position: "relative",
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
{/* Filter pill, highlighted when filterActive */}
|
|
386
|
+
<div style={{ position: "relative" }}>
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
aria-label={filterActive ? "Open filters (filters active)" : "Open filters"}
|
|
390
|
+
onClick={onFilterClick}
|
|
391
|
+
style={{
|
|
392
|
+
display: "flex",
|
|
393
|
+
alignItems: "center",
|
|
394
|
+
justifyContent: "center",
|
|
395
|
+
width: L.iconBtnSize,
|
|
396
|
+
height: L.iconBtnSize,
|
|
397
|
+
borderRadius: L.iconBtnRadius,
|
|
398
|
+
background: filterActive ? T.filterActiveFill : "transparent",
|
|
399
|
+
border: "none",
|
|
400
|
+
cursor: "pointer",
|
|
401
|
+
padding: 0,
|
|
402
|
+
flexShrink: 0,
|
|
403
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
<div
|
|
407
|
+
style={{
|
|
408
|
+
display: "flex",
|
|
409
|
+
alignItems: "center",
|
|
410
|
+
justifyContent: "center",
|
|
411
|
+
width: "100%",
|
|
412
|
+
height: "100%",
|
|
413
|
+
borderRadius: L.barRadius,
|
|
414
|
+
padding: L.iconPillPad,
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
<Icon name="filter_alt" size={L.iconSize} color={iconColor} fill={1} />
|
|
418
|
+
</div>
|
|
419
|
+
</button>
|
|
420
|
+
|
|
421
|
+
{/* Badge dot */}
|
|
422
|
+
{filterBadge && (
|
|
423
|
+
<span
|
|
424
|
+
aria-hidden="true"
|
|
425
|
+
style={{
|
|
426
|
+
position: "absolute",
|
|
427
|
+
top: 1,
|
|
428
|
+
right: 1,
|
|
429
|
+
width: L.badgeSize,
|
|
430
|
+
height: L.badgeSize,
|
|
431
|
+
borderRadius: "50%",
|
|
432
|
+
background: T.badgeFill,
|
|
433
|
+
border: `1.5px solid ${T.badgeBorderColor}`,
|
|
434
|
+
display: "block",
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
447
|
+
// TOP NAV SEARCH
|
|
448
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
449
|
+
/**
|
|
450
|
+
* TopNavSearch: the nested search control for TopNav.Global.
|
|
451
|
+
*
|
|
452
|
+
* Canonical display name: **TopNav.Search** (exposed as `TopNav.Search` compound
|
|
453
|
+
* member in top-nav.jsx; the bare identifier here is `TopNavSearch` because JS
|
|
454
|
+
* identifiers can't contain a dot: same convention as TopNav.Global).
|
|
455
|
+
*
|
|
456
|
+
* Sits on the dark brand-blue nav surface. The COLLAPSED icon button resolves
|
|
457
|
+
* through the DARK-MODE token set (inverse fill + mono icon). The EXPANDED bar is
|
|
458
|
+
* the standard white SearchInput (light-mode), exactly as Figma node 40007095-4048
|
|
459
|
+
* shows. Tapping the search icon inside the expanded bar collapses it; the typed
|
|
460
|
+
* query PERSISTS across collapse/expand (never cleared on collapse).
|
|
461
|
+
*
|
|
462
|
+
* Works controlled OR uncontrolled:
|
|
463
|
+
* - Controlled expand: pass `expanded` + `onExpandChange`.
|
|
464
|
+
* - Uncontrolled (default): manages its own expanded state, just drop it in.
|
|
465
|
+
* - Controlled value: pass `searchProps.value` + `searchProps.onChange`.
|
|
466
|
+
* - Uncontrolled value (default): manages its own query string internally so it
|
|
467
|
+
* persists across collapse/expand without the parent wiring any state.
|
|
468
|
+
*
|
|
469
|
+
* Props:
|
|
470
|
+
* expanded, (optional) controlled expanded state
|
|
471
|
+
* onExpandChange, (optional) (expanded: boolean) => void
|
|
472
|
+
* searchProps: all SearchInput props forwarded (value, onChange, showFilter,
|
|
473
|
+
* filterActive, filterBadge, onFilterClick, onSearch, ...)
|
|
474
|
+
* onSearchOpen, (optional) fired when the bar expands (analytics / focus hook)
|
|
475
|
+
* className, additional class on root element
|
|
476
|
+
*/
|
|
477
|
+
// One-time keyframe for the expanded bar's leftward slide-in (self-contained).
|
|
478
|
+
if (typeof document !== "undefined" && !document.getElementById("pds-topnavsearch-anim")) {
|
|
479
|
+
const s = document.createElement("style");
|
|
480
|
+
s.id = "pds-topnavsearch-anim";
|
|
481
|
+
// THE BAR EXPANDS FROM THE CONTROL IT REPLACES. This was
|
|
482
|
+
// `translateX(16px)` plus a fade, which is a nudge: the takeover appeared
|
|
483
|
+
// full-width and then shuffled 16px sideways, so nothing ever looked like it
|
|
484
|
+
// grew out of the search icon. Reported 2026-10-02.
|
|
485
|
+
//
|
|
486
|
+
// clip-path reveals the bar leftward from the right-hand edge, where the
|
|
487
|
+
// collapsed control sits, so the field is uncovered rather than moved. The
|
|
488
|
+
// content inside never translates or scales, so the label and the back arrow
|
|
489
|
+
// stay sharp and legible for the whole transition, which a scaleX would not
|
|
490
|
+
// manage. The inset starts at the collapsed control's own width so the reveal
|
|
491
|
+
// begins exactly where the icon was.
|
|
492
|
+
s.textContent =
|
|
493
|
+
"@keyframes pwSearchExpand{" +
|
|
494
|
+
"from{clip-path:inset(0 0 0 calc(100% - 48px));opacity:.6}" +
|
|
495
|
+
"to{clip-path:inset(0 0 0 0);opacity:1}}" +
|
|
496
|
+
"@media (prefers-reduced-motion: reduce){" +
|
|
497
|
+
"@keyframes pwSearchExpand{from{clip-path:inset(0);opacity:1}to{clip-path:inset(0);opacity:1}}}";
|
|
498
|
+
document.head.appendChild(s);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function TopNavSearch({
|
|
502
|
+
expanded: expandedProp,
|
|
503
|
+
onExpandChange,
|
|
504
|
+
searchProps = {},
|
|
505
|
+
onSearchOpen,
|
|
506
|
+
breakpoint = "desktop", // on non-desktop, TopNav renders a full-width takeover instead of the 320px bar
|
|
507
|
+
className = "",
|
|
508
|
+
}) {
|
|
509
|
+
const collapsedBtnRef = useRef(null);
|
|
510
|
+
const inputRef = useRef(null);
|
|
511
|
+
const [hov, setHov] = useState(false);
|
|
512
|
+
|
|
513
|
+
// Expanded state: controlled if `expandedProp` provided, else internal.
|
|
514
|
+
const isExpandedControlled = expandedProp !== undefined;
|
|
515
|
+
const [expandedState, setExpandedState] = useState(false);
|
|
516
|
+
const expanded = isExpandedControlled ? expandedProp : expandedState;
|
|
517
|
+
|
|
518
|
+
// Query value: controlled if searchProps.value provided, else internal so the
|
|
519
|
+
// text PERSISTS across collapse/expand (interaction spec requirement).
|
|
520
|
+
const isValueControlled = searchProps.value !== undefined;
|
|
521
|
+
const [queryState, setQueryState] = useState("");
|
|
522
|
+
const query = isValueControlled ? searchProps.value : queryState;
|
|
523
|
+
|
|
524
|
+
const setQuery = (v) => {
|
|
525
|
+
if (!isValueControlled) setQueryState(v);
|
|
526
|
+
searchProps.onChange?.(v);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const expand = () => {
|
|
530
|
+
if (!isExpandedControlled) setExpandedState(true);
|
|
531
|
+
onExpandChange?.(true);
|
|
532
|
+
onSearchOpen?.();
|
|
533
|
+
// Defer focus until the input is in the DOM.
|
|
534
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const collapse = () => {
|
|
538
|
+
if (!isExpandedControlled) setExpandedState(false);
|
|
539
|
+
onExpandChange?.(false);
|
|
540
|
+
// NB: query is intentionally NOT cleared: it persists across collapse/expand.
|
|
541
|
+
setTimeout(() => collapsedBtnRef.current?.focus(), 0);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// Escape key collapses the bar.
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!expanded) return;
|
|
547
|
+
const handler = (e) => { if (e.key === "Escape") collapse(); };
|
|
548
|
+
document.addEventListener("keydown", handler);
|
|
549
|
+
return () => document.removeEventListener("keydown", handler);
|
|
550
|
+
}, [expanded]);
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<div
|
|
554
|
+
className={className}
|
|
555
|
+
style={{
|
|
556
|
+
// While expanded the wrapper deliberately stops being a positioning
|
|
557
|
+
// context. The takeover below is absolute, so with the wrapper relative
|
|
558
|
+
// it could only ever be as wide as this 48px slot: which is how the
|
|
559
|
+
// expanded search ended up as a strip on the right-hand side of the bar
|
|
560
|
+
// instead of taking the bar over. Static hands the takeover up to the
|
|
561
|
+
// nav itself, which is the element that should be covered.
|
|
562
|
+
position: expanded ? "static" : "relative",
|
|
563
|
+
display: "inline-flex",
|
|
564
|
+
alignItems: "center",
|
|
565
|
+
justifyContent: "center",
|
|
566
|
+
// Footprint is ALWAYS the 48px collapsed slot: the expanded bar is absolutely
|
|
567
|
+
// positioned, so expanding never changes layout / never pushes sibling elements.
|
|
568
|
+
width: L.touchTarget,
|
|
569
|
+
height: L.touchTarget,
|
|
570
|
+
minWidth: L.touchTarget,
|
|
571
|
+
flexShrink: 0,
|
|
572
|
+
}}
|
|
573
|
+
>
|
|
574
|
+
{/* Collapsed control, perfect 32×32 circle inside the 48px touch target
|
|
575
|
+
(Figma node 40006967:20917, Container.Icon h32 w32 rounded-full). */}
|
|
576
|
+
<button
|
|
577
|
+
ref={collapsedBtnRef}
|
|
578
|
+
type="button"
|
|
579
|
+
aria-label="Open search"
|
|
580
|
+
aria-expanded={expanded}
|
|
581
|
+
onClick={expand}
|
|
582
|
+
onMouseEnter={() => setHov(true)}
|
|
583
|
+
onMouseLeave={() => setHov(false)}
|
|
584
|
+
style={{
|
|
585
|
+
// Always rendered. The takeover covers the whole bar now, so there is
|
|
586
|
+
// nothing to hide from: the old breakpoint branch existed only because
|
|
587
|
+
// the desktop expansion was an inline bar that sat beside this button.
|
|
588
|
+
display: "flex",
|
|
589
|
+
alignItems: "center",
|
|
590
|
+
justifyContent: "center",
|
|
591
|
+
width: L.touchTarget,
|
|
592
|
+
height: L.touchTarget,
|
|
593
|
+
borderRadius: L.iconBtnRadius,
|
|
594
|
+
background: "transparent",
|
|
595
|
+
border: "none",
|
|
596
|
+
cursor: "pointer",
|
|
597
|
+
padding: 0,
|
|
598
|
+
flexShrink: 0,
|
|
599
|
+
}}
|
|
600
|
+
>
|
|
601
|
+
{/* Fixed 32×32 square + border-radius 50% = always a perfect circle, regardless of flex context */}
|
|
602
|
+
<div
|
|
603
|
+
style={{
|
|
604
|
+
display: "flex",
|
|
605
|
+
alignItems: "center",
|
|
606
|
+
justifyContent: "center",
|
|
607
|
+
width: 32,
|
|
608
|
+
height: 32,
|
|
609
|
+
borderRadius: "50%",
|
|
610
|
+
background: hov ? T.collapsedBtnHover : T.collapsedBtnFill,
|
|
611
|
+
border: `0.5px solid ${hov ? T.collapsedBtnBorderHov : T.collapsedBtnBorder}`,
|
|
612
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard), border-color var(--motion-duration-2) var(--motion-easing-standard)",
|
|
613
|
+
}}
|
|
614
|
+
>
|
|
615
|
+
<Icon
|
|
616
|
+
name="search"
|
|
617
|
+
size={L.iconSizeCollapsed}
|
|
618
|
+
color={hov ? T.navIconHover : T.navIconFill}
|
|
619
|
+
/>
|
|
620
|
+
</div>
|
|
621
|
+
</button>
|
|
622
|
+
|
|
623
|
+
{/* EXPANDED: a takeover of the whole nav, not a bar on the right.
|
|
624
|
+
It used to be absolutely positioned at `right: 0` with a fixed
|
|
625
|
+
`L.expandedWidth`, which made the expanded search a 332px strip over
|
|
626
|
+
the right-hand end of the bar while the org switcher and module
|
|
627
|
+
switcher stayed visible beside it. Reported 2026-10-02.
|
|
628
|
+
|
|
629
|
+
`inset: 0` against the nav (the wrapper goes position:static while
|
|
630
|
+
expanded, so this anchors to the nav, see above) means search owns the
|
|
631
|
+
bar for as long as it is open, on every breakpoint. The only way out
|
|
632
|
+
is the back arrow or Escape, which is why both exist.
|
|
633
|
+
|
|
634
|
+
A consumer that is not the TopNav only has to be position:relative for
|
|
635
|
+
this to cover it. */}
|
|
636
|
+
{expanded && (
|
|
637
|
+
<div
|
|
638
|
+
aria-hidden={false}
|
|
639
|
+
style={{
|
|
640
|
+
position: "absolute",
|
|
641
|
+
inset: 0,
|
|
642
|
+
display: "flex",
|
|
643
|
+
alignItems: "center",
|
|
644
|
+
gap: "var(--semantic-layout-units-gap-xtight)",
|
|
645
|
+
// The bar's own chrome, so the takeover hides what it covers rather
|
|
646
|
+
// than floating over it.
|
|
647
|
+
background: "var(--semantic-color-fill-surface-chrome)",
|
|
648
|
+
padding: "var(--semantic-layout-units-padding-xxtight) var(--semantic-layout-units-padding-base)",
|
|
649
|
+
boxSizing: "border-box",
|
|
650
|
+
zIndex: 60,
|
|
651
|
+
animation: "pwSearchExpand var(--motion-duration-4) var(--motion-easing-spring) both",
|
|
652
|
+
}}
|
|
653
|
+
>
|
|
654
|
+
{/* Exit, to the LEFT and OUTSIDE the field. It used to be the field's
|
|
655
|
+
own leading search glyph, which meant the expanded bar carried two
|
|
656
|
+
controls of similar weight inches apart: the leading glyph threw
|
|
657
|
+
the whole search away and the trailing ✕ threw only the typed text
|
|
658
|
+
away. A back arrow outside the field is a different shape, on the
|
|
659
|
+
other side, and reads as "go back" rather than "close". The
|
|
660
|
+
leading glyph is now a plain search mark with no action. */}
|
|
661
|
+
<button
|
|
662
|
+
type="button"
|
|
663
|
+
aria-label="Back"
|
|
664
|
+
onClick={collapse}
|
|
665
|
+
style={{
|
|
666
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
667
|
+
width: L.exitTarget, height: L.exitTarget, flexShrink: 0,
|
|
668
|
+
background: "transparent", border: "none", cursor: "pointer",
|
|
669
|
+
borderRadius: L.iconBtnRadius, color: T.navIconFill,
|
|
670
|
+
}}
|
|
671
|
+
>
|
|
672
|
+
<Icon name="arrow_back" size={L.exitGlyph} color={T.navIconFill} fill={1} />
|
|
673
|
+
</button>
|
|
674
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
675
|
+
<SearchInput
|
|
676
|
+
{...searchProps}
|
|
677
|
+
value={query}
|
|
678
|
+
onChange={setQuery}
|
|
679
|
+
inputRef={inputRef}
|
|
680
|
+
onSearch={(v) => searchProps.onSearch?.(v)}
|
|
681
|
+
/>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
)}
|
|
685
|
+
</div>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export default SearchInput;
|