/******************************************************************************** * 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 { ChangeEvent, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useId, useRef } from 'react'; import { IconButton, InputBase, InputBaseComponentProps, Box } from '@mui/material'; import { alpha, styled } from '@mui/material/styles'; import { MONO_FONT } from '../default/theme'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Close'; import { focusRing } from './page-primitives'; interface ExtensionSearchfieldProps { onSearchChanged: (s: string) => void; onSearchSubmit?: (s: string) => void; searchQuery?: string; placeholder: string; hideIconButton?: boolean; error?: boolean; autoFocus?: boolean; viewTransitionName?: string; inputProps?: InputBaseComponentProps; } const SearchWrap = styled(Box, { shouldForwardProp: prop => prop !== 'hasError' })<{ hasError?: boolean }>(({ theme, hasError }) => ({ flex: 2, display: 'flex', alignItems: 'center', gap: '0.625rem', border: hasError ? '2px solid' : '1px solid', borderColor: hasError ? theme.palette.error.main : theme.palette.divider, borderRadius: theme.shape.borderRadiusCard, height: '2.8rem', padding: '0 0.8125rem', backgroundColor: alpha(theme.palette.surface2, 0.7), backdropFilter: 'blur(2px)', transition: 'border-color 0.18s, box-shadow 0.18s', '&:focus-within': focusRing(theme) })); const MonoSlash = styled('span')(({ theme }) => ({ fontFamily: MONO_FONT, color: theme.palette.secondary.light, fontSize: '1.0625rem', lineHeight: 1, flexShrink: 0, userSelect: 'none' })); const SearchInput = styled(InputBase)(({ theme }) => ({ flex: 1, fontFamily: MONO_FONT, fontSize: '0.9375rem', color: theme.palette.text.primary, // iOS Safari zooms the viewport on focus when the field's font-size is < 16px; // keep it at 16px on mobile to suppress that (desktop stays compact at 15px). [theme.breakpoints.down('sm')]: { fontSize: '1rem' }, '& input::placeholder': { color: theme.palette.text.primary, opacity: 0.7 }, '& input::-webkit-search-cancel-button': { display: 'none' } })); export const ExtensionSearchfield = forwardRef( (props: ExtensionSearchfieldProps, ref: ForwardedRef) => { const inputRef = useRef(null); const inputId = useId(); // Keep the forwarded ref and the internal ref (used by the clear button) in sync. const setInputRef = useCallback( (node: HTMLInputElement | null) => { inputRef.current = node; if (typeof ref === 'function') { ref(node); } else if (ref) { ref.current = node; } }, [ref] ); const handleSearchChange = (event: ChangeEvent) => { props.onSearchChanged(event.target.value); }; const handleSearchButtonClick = () => { if (props.onSearchSubmit) { props.onSearchSubmit(props.searchQuery ?? ''); } }; const handleClear = () => { props.onSearchChanged(''); inputRef.current?.focus(); }; // Merged into inputProps because InputBase spreads inputProps after its own // onKeyDown, so a caller-supplied handler would silently replace Enter-submit. const handleKeyDown = (event: KeyboardEvent) => { props.inputProps?.onKeyDown?.(event); if (event.key === 'Enter' && props.onSearchSubmit && !event.defaultPrevented) { props.onSearchSubmit(props.searchQuery ?? ''); } }; return ( / {props.searchQuery && ( )} {!props.hideIconButton && ( )} ); } ); ExtensionSearchfield.displayName = 'ExtensionSearchfield';