@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.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.
@@ -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;