Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 9x 9x 9x 9x 9x 1x 1x 9x 9x 9x 9x 12x 12x 1x 1x 1x 1x 1x 12x 3x 3x 1x 1x 2x 3x 1x 1x 1x 1x 1x 1x 3x 12x 2x 1x 1x 1x 1x 2x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 1x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 1x 11x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x | import { CACHE } from '@/constants/common';
import { i18n } from '@/helpers/i18n';
import { countActiveSearchFilters } from '@/helpers/searchFilters';
import { cstorage } from '@/utils/storage';
import ScienceIcon from '@mui/icons-material/Science';
import SearchIcon from '@mui/icons-material/Search';
import { Box, IconButton, Tooltip } from '@mui/material';
import { FC, FormEvent, useEffect, useState } from 'react';
import { useAppContext } from '../context';
import styles from './SearchForm.module.scss';
import HighlightedSearchInput from './SearchPanel/HighlightedSearchInput';
import { useDelayedError } from './SearchPanel/useDelayedError.hook';
import { SearchFormDivider, SearchFormPaper } from './StyledComponents';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
const logger = new Logger('SearchForm');
/**
* Props for {@link SearchForm}. Controls submit handling, the advanced-options
* button behavior, and the input's placeholder text.
* @example
* ```tsx
* const props: SearchFormProps = {
* onSearch: (q) => runSearch(q),
* placeholder: "Search chemicals...",
* };
* ```
* @source
*/
interface SearchFormProps {
/** Called with the trimmed query when the form is submitted. */
onSearch: (query: string) => void;
/** Overrides the advanced-options button action; defaults to toggling the app drawer. */
onDrawerToggle?: () => void;
/** Placeholder text for the input. Defaults to `"Search for products..."`. */
placeholder?: string;
/** Whether to show the advanced-options button. */
showAdvancedButton?: boolean;
/** When true, focuses the query input on mount (e.g. on entering the search panel). */
autoFocus?: boolean;
}
/**
* Renders the primary search bar: a text input bound to the shared
* `searchFilters.titleQuery`, a submit button, and an advanced-options button
* that opens the settings drawer. The input hydrates from and persists to
* session storage so the query survives popup re-opens.
* @param props - The {@link SearchFormProps} controlling submit handling,
* placeholder text, and the drawer-toggle override.
* @returns The search form element.
* @example
* ```tsx
* <SearchForm onSearch={(q) => runSearch(q)} placeholder="Search chemicals..." />
* // Renders a search box; submitting "acetone" calls onSearch("acetone").
* ```
* @source
*/
export const SearchForm: FC<SearchFormProps> = ({
onSearch,
onDrawerToggle,
placeholder = i18n('search_placeholder'),
autoFocus,
}) => {
const appContext = useAppContext();
const { searchFilters, setSearchFilters } = appContext;
const query = searchFilters.titleQuery;
// Blue-highlight the advanced-search button (and note the count on hover) when
// any drawer filter is set to a non-default value.
const activeFilterCount = countActiveSearchFilters({
selectedSuppliers: appContext.selectedSuppliers,
searchFilters,
userSettings: appContext.userSettings,
});
// Set when the typed query is an invalid advanced query; blocks submit until fixed.
const [searchError, setSearchError] = useState<string | undefined>(undefined);
// Debounced copy for display only — the message appears after the user pauses typing.
const hintMessage = useDelayedError(searchError, query, 200);
// Hydrate the shared title query from the persisted draft in session storage
// so this field reflects whatever was last typed in any search input — even
// across popup re-opens. Both this component and DrawerSearchPanel bind to
// searchFilters.titleQuery, so once context is populated they stay in sync
// automatically without re-reading the cache.
useEffect(() => {
const loadSearchInput = async () => {
try {
const data = await cstorage.session.get([CACHE.SEARCH_INPUT]);
const stored = data[CACHE.SEARCH_INPUT];
if (typeof stored === 'string' && stored && stored !== searchFilters.titleQuery) {
setSearchFilters({ ...searchFilters, titleQuery: stored });
}
} catch (error) {
logger.warn(`Failed to load search input from session storage: ${getErrorMessage(error)}`, {
error,
});
}
};
loadSearchInput();
// Run once on mount; intentionally don't depend on searchFilters to avoid loops.
}, []);
const handleChange = async (value: string) => {
setSearchFilters({ ...searchFilters, titleQuery: value });
try {
await cstorage.session.set({ [CACHE.SEARCH_INPUT]: value });
} catch (error) {
logger.warn(`Failed to persist search input to session storage: ${getErrorMessage(error)}`, {
error,
});
}
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (searchError) {
return;
}
const trimmed = query.trim();
if (trimmed) {
// Clear the live draft so re-opening the drawer doesn't show stale text
// from the just-submitted query. The parent's onSearch handler is also
// responsible for clearing CACHE.SEARCH_INPUT in session storage.
setSearchFilters({ ...searchFilters, titleQuery: '' });
onSearch(trimmed);
}
};
const handleDrawerToggle = () => {
if (onDrawerToggle) {
onDrawerToggle();
} else {
appContext.toggleDrawer();
}
};
return (
<Box className={styles['search-form-container']} data-testid="search-form-container">
<SearchFormPaper onSubmit={handleSubmit}>
<HighlightedSearchInput
value={query}
onChange={handleChange}
placeholder={placeholder}
ariaLabel={i18n('search_form_aria')}
autoFocus={autoFocus}
onValidityChange={(blocked, message) =>
setSearchError(blocked ? (message ?? i18n('search_invalid_query')) : undefined)
}
style={{ marginLeft: 16, flex: 1, fontSize: '1.15rem' }}
/>
<IconButton
className={styles['search-form-icon-button']}
type="submit"
aria-label={i18n('search_submit')}
disabled={!query.trim() || Boolean(searchError)}
>
<SearchIcon />
</IconButton>
<SearchFormDivider />
<Tooltip
title={
activeFilterCount > 0
? i18n('search_active_filters', [String(activeFilterCount)])
: i18n('search_advanced_options')
}
>
<IconButton
className={styles['search-form-icon-button']}
type="button"
aria-label={i18n('search_advanced_options')}
onClick={handleDrawerToggle}
sx={activeFilterCount > 0 ? { color: '#4e73af' } : undefined}
>
<ScienceIcon />
</IconButton>
</Tooltip>
</SearchFormPaper>
{hintMessage && (
<div role="alert" className={styles['search-error-hint']}>
{hintMessage}
</div>
)}
</Box>
);
};
|