/******************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * https://www.eclipse.org/legal/epl-2.0 * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ import { FocusEvent, FunctionComponent, KeyboardEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box } from '@mui/material'; import { useLocation } from 'react-router'; import { ExtensionSearchfield } from '../components/extension-searchfield'; import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; import { useSearch } from '../hooks/use-search'; import { useSearchQuery } from '../context/search/search-context'; import { useSearchFocus } from '../context/search/search-focus-context'; import { usePageSearchBar } from '../context/search/page-search-bar-context'; import { useSignalEffect } from '../hooks/use-signal-effect'; import { useShortcut } from '../hooks/use-shortcut'; export const NavSearchField: FunctionComponent = () => { const { pathname } = useLocation(); const { query } = useSearchQuery(); const { search, filter } = useSearch(); const { searchFocusSignal, resultsNavigationSignal, setSearchFocused } = useSearchFocus(); const { hasPageSearchBar, isPageSearchBarActive } = usePageSearchBar(); const inputRef = useRef(null); // Suppress the show/hide fade on first paint. The home hero registers its page // search bar in a layout effect, so this field starts visible and is hidden in // the same commit — without this it would fade out from opacity 1 on load. const [fadeReady, setFadeReady] = useState(false); useEffect(() => { const id = requestAnimationFrame(() => setFadeReady(true)); return () => cancelAnimationFrame(id); }, []); // While a page search bar is registered, this field is only an opacity-0 // placeholder for the view-transition morph; `inert` removes it from the tab // order and accessibility tree. `pendingFocus` holds a focus request that // arrived in the commit that unregistered the page search bar (before this // component re-rendered); it is honored here once the field is visible again. const fieldRef = useRef(null); const pendingFocus = useRef(false); useLayoutEffect(() => { if (fieldRef.current) { fieldRef.current.inert = hasPageSearchBar; } if (!hasPageSearchBar && pendingFocus.current) { pendingFocus.current = false; inputRef.current?.focus({ preventScroll: true }); } }, [hasPageSearchBar]); // Take focus when requested — unless a page search bar is registered and owns focus instead. useSignalEffect( searchFocusSignal, useCallback(() => { if (isPageSearchBarActive()) { return; } if (hasPageSearchBar) { // Stale commit: the bar just unregistered; defer to the layout effect above. pendingFocus.current = true; return; } inputRef.current?.focus({ preventScroll: true }); }, [isPageSearchBarActive, hasPageSearchBar]) ); // The '/' shortcut asks whichever search field is active to take focus. useShortcut({ key: '/', label: 'Focus search', order: 1, callback: searchFocusSignal.emit }); const handleNavSearch = useCallback((q: string) => search({ query: q }, { debounce: true }), [search]); const handleNavSubmit = useCallback( (q: string) => { // On the search page, Enter on an already-applied query opens the card // under the cursor; on a fresh query it applies the search first. if (pathname === ExtensionListRoutes.SEARCH && q.trim() === filter.query) { resultsNavigationSignal.emit('open'); return; } search({ query: q }); }, [search, pathname, filter.query, resultsNavigationSignal.emit] ); // Move cursor to end when the input gains focus (e.g. after view-transition morphs // the hero search into this field — browsers select-all by default on programmatic focus) const handleInputFocus = useCallback( (e: FocusEvent) => { setSearchFocused(true); const { target } = e; requestAnimationFrame(() => target.setSelectionRange(target.value.length, target.value.length)); }, [setSearchFocused] ); const handleInputBlur = useCallback(() => setSearchFocused(false), [setSearchFocused]); // On the search page the input and the results share one cursor: Up/Down // step it through the results item by item while focus (and the text caret) // stays in the field, and Enter opens it. Escape blurs the field. const handleInputKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { // preventDefault stops WebKit/Blink from also clearing the search input. e.preventDefault(); (e.target as HTMLInputElement).blur(); return; } if (pathname !== ExtensionListRoutes.SEARCH) { return; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); resultsNavigationSignal.emit(e.key === 'ArrowDown' ? 'next' : 'previous'); } }, [resultsNavigationSignal.emit, pathname] ); return ( ); };