import * as React from 'react'; import TextareaAutosize from 'react-autosize-textarea'; import {withRouter, WithRouterProps} from 'react-router'; import isPropValid from '@emotion/is-prop-valid'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import debounce from 'lodash/debounce'; import {addErrorMessage} from 'app/actionCreators/indicator'; import {fetchRecentSearches, saveRecentSearch} from 'app/actionCreators/savedSearches'; import {Client} from 'app/api'; import ButtonBar from 'app/components/buttonBar'; import DropdownLink from 'app/components/dropdownLink'; import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import renderQuery from 'app/components/searchSyntax/renderer'; import { DEFAULT_DEBOUNCE_DURATION, MAX_AUTOCOMPLETE_RELEASES, NEGATION_OPERATOR, } from 'app/constants'; import {IconClose, IconEllipsis, IconSearch} from 'app/icons'; import {t} from 'app/locale'; import MemberListStore from 'app/stores/memberListStore'; import space from 'app/styles/space'; import {LightWeightOrganization, SavedSearchType, Tag, User} from 'app/types'; import {defined} from 'app/utils'; import {trackAnalyticsEvent} from 'app/utils/analytics'; import {callIfFunction} from 'app/utils/callIfFunction'; import withApi from 'app/utils/withApi'; import withOrganization from 'app/utils/withOrganization'; import {ActionButton} from './actions'; import SearchDropdown from './searchDropdown'; import {ItemType, SearchGroup, SearchItem} from './types'; import { addSpace, createSearchGroups, filterSearchGroupsByIndex, getLastTermIndex, getQueryTerms, removeSpace, } from './utils'; const DROPDOWN_BLUR_DURATION = 200; /** * The max width in pixels of the search bar at which the buttons will * have overflowed into the dropdown. */ const ACTION_OVERFLOW_WIDTH = 400; /** * Actions are moved to the overflow dropdown after each pixel step is reached. */ const ACTION_OVERFLOW_STEPS = 75; /** * Is the SearchItem a default item */ const isDefaultDropdownItem = (item: SearchItem) => item?.type === 'default'; type ActionProps = { api: Client; /** * Render the actions as a menu item */ menuItemVariant?: boolean; /** * The current query */ query: string; /** * The organization */ organization: LightWeightOrganization; /** * The saved search type passed to the search bar */ savedSearchType?: SavedSearchType; }; type ActionBarItem = { /** * Name of the action */ key: string; /** * The action component to render */ Action: React.ComponentType; }; type Props = WithRouterProps & { api: Client; organization: LightWeightOrganization; dropdownClassName?: string; className?: string; defaultQuery?: string; query?: string | null; /** * Additional components to render as actions on the right of the search bar */ actionBarItems?: ActionBarItem[]; /** * Prepare query value before filtering dropdown items */ prepareQuery?: (query: string) => string; /** * Search items to display when there's no tag key. Is a tuple of search * items and recent search items */ defaultSearchItems?: [SearchItem[], SearchItem[]]; /** * Disabled control (e.g. read-only) */ disabled?: boolean; /** * Input placeholder */ placeholder?: string; /** * Allows additional content to be played before the search bar and icon */ inlineLabel?: React.ReactNode; /** * Map of tags */ supportedTags?: {[key: string]: Tag}; /** * Maximum number of search items to display or a falsey value for no * maximum */ maxSearchItems?: number; /** * List user's recent searches */ hasRecentSearches?: boolean; /** * Wrap the input with a form. Useful if search bar is used within a parent * form */ useFormWrapper?: boolean; /** * If this is defined, attempt to save search term scoped to the user and * the current org */ savedSearchType?: SavedSearchType; /** * Get a list of tag values for the passed tag */ onGetTagValues?: (tag: Tag, query: string, params: object) => Promise; /** * Get a list of recent searches for the current query */ onGetRecentSearches?: (query: string) => Promise; /** * Called when the user makes a search */ onSearch?: (query: string) => void; /** * Called when the search input changes */ onChange?: (value: string, e: React.ChangeEvent) => void; /** * Called when the search is blurred */ onBlur?: (value: string) => void; /** * Called on key down */ onKeyDown?: (evt: React.KeyboardEvent) => void; /** * Called when a recent search is saved */ onSavedRecentSearch?: (query: string) => void; /** * If true, excludes the environment tag from the autocompletion list. This * is because we don't want to treat environment as a tag in some places such * as the stream view where it is a top level concept */ excludeEnvironment?: boolean; /** * Used to enforce length on the query */ maxQueryLength?: number; /** * While the data is unused, this list of members can be updated to * trigger re-renders. */ members?: User[]; }; type State = { /** * The current search query in the input */ query: string; /** * The query in the input since we last updated our autocomplete list. */ previousQuery?: string; /** * Indicates that we have a query that we've already determined not to have * any values. This is used to stop the autocompleter from querying if we * know we will find nothing. */ noValueQuery?: string; /** * The current search term (or 'key') that that we will be showing * autocompletion for. */ searchTerm: string; searchGroups: SearchGroup[]; flatSearchItems: SearchItem[]; /** * Index of the focused search item */ activeSearchItem: number; tags: Record; dropdownVisible: boolean; loading: boolean; /** * The number of actions that are not in the overflow menu. */ numActionsVisible: number; }; class SmartSearchBar extends React.Component { static defaultProps = { defaultQuery: '', query: null, onSearch: function () {}, excludeEnvironment: false, placeholder: t('Search for events, users, tags, and more'), supportedTags: {}, defaultSearchItems: [[], []], useFormWrapper: true, savedSearchType: SavedSearchType.ISSUE, }; state: State = { query: this.props.query !== null ? addSpace(this.props.query) : this.props.defaultQuery ?? '', searchTerm: '', searchGroups: [], flatSearchItems: [], activeSearchItem: -1, tags: {}, dropdownVisible: false, loading: false, numActionsVisible: this.props.actionBarItems?.length ?? 0, }; componentDidMount() { if (!window.ResizeObserver) { return; } if (this.containerRef.current === null) { return; } this.inputResizeObserver = new ResizeObserver(this.updateActionsVisible); this.inputResizeObserver.observe(this.containerRef.current); } componentDidUpdate(prevProps: Props) { const {query} = this.props; const {query: lastQuery} = prevProps; if (query !== lastQuery && defined(query)) { // eslint-disable-next-line react/no-did-update-set-state this.setState({query: addSpace(query)}); } } componentWillUnmount() { this.inputResizeObserver?.disconnect(); if (this.blurTimeout) { clearTimeout(this.blurTimeout); } } /** * Tracks the dropdown blur */ blurTimeout?: number; /** * Ref to the search element itself */ searchInput = React.createRef(); /** * Ref to the search container */ containerRef = React.createRef(); /** * Used to determine when actions should be moved to the action overflow menu */ inputResizeObserver: ResizeObserver | null = null; /** * Updates the numActionsVisible count as the search bar is resized */ updateActionsVisible = (entries: ResizeObserverEntry[]) => { if (entries.length === 0) { return; } const entry = entries[0]; const {width} = entry.contentRect; const actionCount = this.props.actionBarItems?.length ?? 0; const numActionsVisible = Math.min( actionCount, Math.floor(Math.max(0, width - ACTION_OVERFLOW_WIDTH) / ACTION_OVERFLOW_STEPS) ); if (this.state.numActionsVisible === numActionsVisible) { return; } this.setState({numActionsVisible}); }; blur() { if (!this.searchInput.current) { return; } this.searchInput.current.blur(); } async doSearch() { const {onSearch, onSavedRecentSearch, api, organization, savedSearchType} = this.props; this.blur(); const query = removeSpace(this.state.query); callIfFunction(onSearch, query); // Only save recent search query if we have a savedSearchType (also 0 is a valid value) // Do not save empty string queries (i.e. if they clear search) if (typeof savedSearchType === 'undefined' || !query) { return; } try { await saveRecentSearch(api, organization.slug, savedSearchType, query); if (onSavedRecentSearch) { onSavedRecentSearch(query); } } catch (err) { // Silently capture errors if it fails to save Sentry.captureException(err); } } onSubmit = (evt: React.FormEvent) => { const {organization, savedSearchType} = this.props; evt.preventDefault(); trackAnalyticsEvent({ eventKey: 'search.searched', eventName: 'Search: Performed search', organization_id: organization.id, query: removeSpace(this.state.query), search_type: savedSearchType === 0 ? 'issues' : 'events', search_source: 'main_search', }); this.doSearch(); }; clearSearch = () => this.setState({query: ''}, () => callIfFunction(this.props.onSearch, this.state.query) ); onQueryFocus = () => this.setState({dropdownVisible: true}); onQueryBlur = (e: React.FocusEvent) => { // wait before closing dropdown in case blur was a result of clicking a // menu option const value = e.target.value; const blurHandler = () => { this.blurTimeout = undefined; this.setState({dropdownVisible: false}); callIfFunction(this.props.onBlur, value); }; this.blurTimeout = window.setTimeout(blurHandler, DROPDOWN_BLUR_DURATION); }; onQueryChange = (evt: React.ChangeEvent) => { const query = evt.target.value.replace('\n', ''); this.setState({query}, this.updateAutoCompleteItems); callIfFunction(this.props.onChange, evt.target.value, evt); }; onInputClick = () => this.updateAutoCompleteItems(); /** * Handle keyboard navigation */ onKeyDown = (evt: React.KeyboardEvent) => { const {onKeyDown} = this.props; const {key} = evt; callIfFunction(onKeyDown, evt); if (!this.state.searchGroups.length) { return; } const isSelectingDropdownItems = this.state.activeSearchItem !== -1; if (key === 'ArrowDown' || key === 'ArrowUp') { evt.preventDefault(); const {flatSearchItems, activeSearchItem} = this.state; const searchGroups = [...this.state.searchGroups]; const [groupIndex, childrenIndex] = isSelectingDropdownItems ? filterSearchGroupsByIndex(searchGroups, activeSearchItem) : []; // Remove the previous 'active' property if (typeof groupIndex !== 'undefined') { if ( childrenIndex !== undefined && searchGroups[groupIndex]?.children?.[childrenIndex] ) { delete searchGroups[groupIndex].children[childrenIndex].active; } } const currIndex = isSelectingDropdownItems ? activeSearchItem : 0; const totalItems = flatSearchItems.length; // Move the selected index up/down const nextActiveSearchItem = key === 'ArrowUp' ? (currIndex - 1 + totalItems) % totalItems : isSelectingDropdownItems ? (currIndex + 1) % totalItems : 0; const [nextGroupIndex, nextChildrenIndex] = filterSearchGroupsByIndex( searchGroups, nextActiveSearchItem ); // Make sure search items exist (e.g. both groups could be empty) and // attach the 'active' property to the item if ( nextGroupIndex !== undefined && nextChildrenIndex !== undefined && searchGroups[nextGroupIndex]?.children ) { searchGroups[nextGroupIndex].children[nextChildrenIndex] = { ...searchGroups[nextGroupIndex].children[nextChildrenIndex], active: true, }; } this.setState({searchGroups, activeSearchItem: nextActiveSearchItem}); } if ((key === 'Tab' || key === 'Enter') && isSelectingDropdownItems) { evt.preventDefault(); const {activeSearchItem, searchGroups} = this.state; const [groupIndex, childrenIndex] = filterSearchGroupsByIndex( searchGroups, activeSearchItem ); const item = groupIndex !== undefined && childrenIndex !== undefined && searchGroups[groupIndex].children[childrenIndex]; if (item && !isDefaultDropdownItem(item)) { this.onAutoComplete(item.value, item); } return; } if (key === 'Enter' && !isSelectingDropdownItems) { this.doSearch(); return; } }; onKeyUp = (evt: React.KeyboardEvent) => { // Other keys are managed at onKeyDown function if (evt.key !== 'Escape') { return; } evt.preventDefault(); const isSelectingDropdownItems = this.state.activeSearchItem > -1; if (!isSelectingDropdownItems) { this.blur(); return; } const {searchGroups, activeSearchItem} = this.state; const [groupIndex, childrenIndex] = isSelectingDropdownItems ? filterSearchGroupsByIndex(searchGroups, activeSearchItem) : []; if (groupIndex !== undefined && childrenIndex !== undefined) { delete searchGroups[groupIndex].children[childrenIndex].active; } this.setState({ activeSearchItem: -1, searchGroups: [...this.state.searchGroups], }); }; getCursorPosition() { if (!this.searchInput.current) { return -1; } return this.searchInput.current.selectionStart ?? -1; } /** * Returns array of possible key values that substring match `query` */ getTagKeys(query: string): SearchItem[] { const {prepareQuery} = this.props; const supportedTags = this.props.supportedTags ?? {}; // Return all if query is empty let tagKeys = Object.keys(supportedTags).map(key => `${key}:`); if (query) { const preparedQuery = typeof prepareQuery === 'function' ? prepareQuery(query) : query; tagKeys = tagKeys.filter(key => key.indexOf(preparedQuery) > -1); } // If the environment feature is active and excludeEnvironment = true // then remove the environment key if (this.props.excludeEnvironment) { tagKeys = tagKeys.filter(key => key !== 'environment:'); } return tagKeys.map(value => ({value, desc: value})); } /** * Returns array of tag values that substring match `query`; invokes `callback` * with data when ready */ getTagValues = debounce( async (tag: Tag, query: string) => { // Strip double quotes if there are any query = query.replace(/"/g, '').trim(); if (!this.props.onGetTagValues) { return []; } if ( this.state.noValueQuery !== undefined && query.startsWith(this.state.noValueQuery) ) { return []; } const {location} = this.props; const endpointParams = getParams(location.query); this.setState({loading: true}); let values: string[] = []; try { values = await this.props.onGetTagValues(tag, query, endpointParams); this.setState({loading: false}); } catch (err) { this.setState({loading: false}); Sentry.captureException(err); return []; } if (tag.key === 'release:' && !values.includes('latest')) { values.unshift('latest'); } const noValueQuery = values.length === 0 && query.length > 0 ? query : undefined; this.setState({noValueQuery}); return values.map(value => { // Wrap in quotes if there is a space const escapedValue = value.includes(' ') || value.includes('"') ? `"${value.replace(/"/g, '\\"')}"` : value; return {value: escapedValue, desc: escapedValue, type: 'tag-value' as ItemType}; }); }, DEFAULT_DEBOUNCE_DURATION, {leading: true} ); /** * Returns array of tag values that substring match `query`; invokes `callback` * with results */ getPredefinedTagValues = (tag: Tag, query: string): SearchItem[] => (tag.values ?? []) .filter(value => value.indexOf(query) > -1) .map((value, i) => ({ value, desc: value, type: 'tag-value', ignoreMaxSearchItems: tag.maxSuggestedValues ? i < tag.maxSuggestedValues : false, })); /** * Get recent searches */ getRecentSearches = debounce( async () => { const {savedSearchType, hasRecentSearches, onGetRecentSearches} = this.props; // `savedSearchType` can be 0 if (!defined(savedSearchType) || !hasRecentSearches) { return []; } const fetchFn = onGetRecentSearches || this.fetchRecentSearches; return await fetchFn(this.state.query); }, DEFAULT_DEBOUNCE_DURATION, {leading: true} ); fetchRecentSearches = async (fullQuery: string): Promise => { const {api, organization, savedSearchType} = this.props; if (savedSearchType === undefined) { return []; } try { const recentSearches: any[] = await fetchRecentSearches( api, organization.slug, savedSearchType, fullQuery ); // If `recentSearches` is undefined or not an array, the function will // return an array anyway return recentSearches.map(searches => ({ desc: searches.query, value: searches.query, type: 'recent-search', })); } catch (e) { Sentry.captureException(e); } return []; }; getReleases = debounce( async (tag: Tag, query: string) => { const releasePromise = this.fetchReleases(query); const tags = this.getPredefinedTagValues(tag, query); const tagValues = tags.map(v => ({ ...v, type: 'first-release', })); const releases = await releasePromise; const releaseValues = releases.map((r: any) => ({ value: r.shortVersion, desc: r.shortVersion, type: 'first-release', })); return [...tagValues, ...releaseValues]; }, DEFAULT_DEBOUNCE_DURATION, {leading: true} ); /** * Fetches latest releases from a organization/project. Returns an empty array * if an error is encountered. */ fetchReleases = async (releaseVersion: string): Promise => { const {api, location, organization} = this.props; const project = location && location.query ? location.query.projectId : undefined; const url = `/organizations/${organization.slug}/releases/`; const fetchQuery: {[key: string]: string | number} = { per_page: MAX_AUTOCOMPLETE_RELEASES, }; if (releaseVersion) { fetchQuery.query = releaseVersion; } if (project) { fetchQuery.project = project; } try { return await api.requestPromise(url, { method: 'GET', query: fetchQuery, }); } catch (e) { addErrorMessage(t('Unable to fetch releases')); Sentry.captureException(e); } return []; }; updateAutoCompleteItems = async () => { if (this.blurTimeout) { clearTimeout(this.blurTimeout); this.blurTimeout = undefined; } const cursor = this.getCursorPosition(); let query = this.state.query; // Don't continue if the query hasn't changed if (query === this.state.previousQuery) { return; } this.setState({previousQuery: query}); const lastTermIndex = getLastTermIndex(query, cursor); const terms = getQueryTerms(query, lastTermIndex); if ( !terms || // no terms terms.length === 0 || // no terms (terms.length === 1 && terms[0] === this.props.defaultQuery) || // default term /^\s+$/.test(query.slice(cursor - 1, cursor + 1)) ) { const [defaultSearchItems, defaultRecentItems] = this.props.defaultSearchItems!; if (!defaultSearchItems.length) { // Update searchTerm, otherwise will have wrong state // (e.g. if you delete a query, the last letter will be highlighted if `searchTerm` // does not get updated) this.setState({searchTerm: query}); const tagKeys = this.getTagKeys(''); const recentSearches = await this.getRecentSearches(); this.updateAutoCompleteState(tagKeys, recentSearches ?? [], '', 'tag-key'); return; } // cursor on whitespace show default "help" search terms this.setState({searchTerm: ''}); this.updateAutoCompleteState(defaultSearchItems, defaultRecentItems, '', 'default'); return; } const last = terms.pop() ?? ''; let autoCompleteItems: SearchItem[]; let matchValue: string; let tagName: string; const index = last.indexOf(':'); if (index === -1) { // No colon present; must still be deciding key matchValue = last.replace(new RegExp(`^${NEGATION_OPERATOR}`), ''); autoCompleteItems = this.getTagKeys(matchValue); const recentSearches = await this.getRecentSearches(); this.setState({searchTerm: matchValue}); this.updateAutoCompleteState( autoCompleteItems, recentSearches ?? [], matchValue, 'tag-key' ); return; } const {prepareQuery, excludeEnvironment} = this.props; const supportedTags = this.props.supportedTags ?? {}; // TODO(billy): Better parsing for these examples // // > sentry:release: // > url:"http://with/colon" tagName = last.slice(0, index); // e.g. given "!gpu" we want "gpu" tagName = tagName.replace(new RegExp(`^${NEGATION_OPERATOR}`), ''); query = last.slice(index + 1); const preparedQuery = typeof prepareQuery === 'function' ? prepareQuery(query) : query; // filter existing items immediately, until API can return // with actual tag value results const filteredSearchGroups = !preparedQuery ? this.state.searchGroups : this.state.searchGroups.filter( item => item.value && item.value.indexOf(preparedQuery) !== -1 ); this.setState({ searchTerm: query, searchGroups: filteredSearchGroups, }); const tag = supportedTags[tagName]; if (!tag) { this.updateAutoCompleteState([], [], tagName, 'invalid-tag'); return; } // Ignore the environment tag if the feature is active and // excludeEnvironment = true if (excludeEnvironment && tagName === 'environment') { return; } const fetchTagValuesFn = tag.key === 'firstRelease' ? this.getReleases : tag.predefined ? this.getPredefinedTagValues : this.getTagValues; const [tagValues, recentSearches] = await Promise.all([ fetchTagValuesFn(tag, preparedQuery), this.getRecentSearches(), ]); this.updateAutoCompleteState( tagValues ?? [], recentSearches ?? [], tag.key, 'tag-value' ); return; }; /** * Updates autocomplete dropdown items and autocomplete index state * * @param searchItems List of search item objects with keys: title, desc, value * @param recentSearchItems List of recent search items, same format as searchItem * @param tagName The current tag name in scope * @param type Defines the type/state of the dropdown menu items */ updateAutoCompleteState( searchItems: SearchItem[], recentSearchItems: SearchItem[], tagName: string, type: ItemType ) { const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props; const query = this.state.query; const queryCharsLeft = maxQueryLength && query ? maxQueryLength - query.length : undefined; this.setState( createSearchGroups( searchItems, hasRecentSearches ? recentSearchItems : undefined, tagName, type, maxSearchItems, queryCharsLeft ) ); } onAutoComplete = (replaceText: string, item: SearchItem) => { if (item.type === 'recent-search') { trackAnalyticsEvent({ eventKey: 'search.searched', eventName: 'Search: Performed search', organization_id: this.props.organization.id, query: replaceText, source: this.props.savedSearchType === 0 ? 'issues' : 'events', search_source: 'recent_search', }); this.setState({query: replaceText}, () => { // Propagate onSearch and save to recent searches this.doSearch(); }); return; } const cursor = this.getCursorPosition(); const query = this.state.query; const lastTermIndex = getLastTermIndex(query, cursor); const terms = getQueryTerms(query, lastTermIndex); let newQuery: string; // If not postfixed with : (tag value), add trailing space replaceText += item.type !== 'tag-value' || cursor < query.length ? '' : ' '; const isNewTerm = query.charAt(query.length - 1) === ' ' && item.type !== 'tag-value'; if (!terms) { newQuery = replaceText; } else if (isNewTerm) { newQuery = `${query}${replaceText}`; } else { const last = terms.pop() ?? ''; newQuery = query.slice(0, lastTermIndex); // get text preceding last term const prefix = last.startsWith(NEGATION_OPERATOR) ? NEGATION_OPERATOR : ''; // newQuery is all the terms up to the current term: "... :" // replaceText should be the selected value if (last.indexOf(':') > -1) { let replacement = `:${replaceText}`; // The user tag often contains : within its value and we need to quote it. if (last.startsWith('user:')) { const colonIndex = replaceText.indexOf(':'); if (colonIndex > -1) { replacement = `:"${replaceText.trim()}"`; } } // tag key present: replace everything after colon with replaceText newQuery = newQuery.replace(/\:"[^"]*"?$|\:\S*$/, replacement); } else { // no tag key present: replace last token with replaceText newQuery = newQuery.replace(/\S+$/, `${prefix}${replaceText}`); } newQuery = newQuery.concat(query.slice(lastTermIndex)); } this.setState({query: newQuery}, () => { // setting a new input value will lose focus; restore it if (this.searchInput.current) { this.searchInput.current.focus(); } // then update the autocomplete box with new items this.updateAutoCompleteItems(); this.props.onChange?.(newQuery, new MouseEvent('click') as any); }); }; render() { const { api, className, savedSearchType, dropdownClassName, actionBarItems, organization, placeholder, disabled, useFormWrapper, inlineLabel, maxQueryLength, } = this.props; const {query, searchGroups, searchTerm, dropdownVisible, numActionsVisible, loading} = this.state; const hasSyntaxHighlight = organization.features.includes('search-syntax-highlight'); const input = ( ); // Segment actions into visible and overflowed groups const actionItems = actionBarItems ?? []; const actionProps = { api, organization, query, savedSearchType, }; const visibleActions = actionItems .slice(0, numActionsVisible) .map(({key, Action}) => ); const overflowedActions = actionItems .slice(numActionsVisible) .map(({key, Action}) => ); return ( {inlineLabel} {hasSyntaxHighlight && {renderQuery(query)}} {useFormWrapper ?
{input}
: input}
{query !== '' && ( } title={t('Clear search')} aria-label={t('Clear search')} /> )} {visibleActions} {overflowedActions.length > 0 && ( } /> } > {overflowedActions} )} {(loading || searchGroups.length > 0) && ( )}
); } } type ContainerState = { members: ReturnType; }; class SmartSearchBarContainer extends React.Component { state: ContainerState = { members: MemberListStore.getAll(), }; componentWillUnmount() { this.unsubscribe(); } unsubscribe = MemberListStore.listen( (members: ContainerState['members']) => this.setState({members}), undefined ); render() { // SmartSearchBar doesn't use members, but we forward it to cause a re-render. return ; } } export default withApi(withRouter(withOrganization(SmartSearchBarContainer))); export {SmartSearchBar}; const Container = styled('div')<{isOpen: boolean}>` border: 1px solid ${p => p.theme.border}; box-shadow: inset ${p => p.theme.dropShadowLight}; background: ${p => p.theme.background}; padding: 7px ${space(1)}; position: relative; display: grid; grid-template-columns: max-content 1fr max-content; grid-gap: ${space(1)}; align-items: start; border-radius: ${p => p.isOpen ? `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0` : p.theme.borderRadius}; .show-sidebar & { background: ${p => p.theme.backgroundSecondary}; } `; const SearchLabel = styled('label')` display: flex; padding: ${space(0.5)} 0; margin: 0; color: ${p => p.theme.gray300}; `; const InputWrapper = styled('div')` position: relative; `; const Highlight = styled('div')` position: absolute; top: 0; left: 0; right: 0; bottom: 0; user-select: none; white-space: pre-wrap; line-height: 25px; font-size: ${p => p.theme.fontSizeSmall}; font-family: ${p => p.theme.text.familyMono}; `; const SearchInput = styled(TextareaAutosize, { shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop), })<{hiddenText: boolean}>` position: relative; display: flex; resize: none; outline: none; border: 0; width: 100%; padding: 0; line-height: 25px; margin-bottom: -1px; background: transparent; font-size: ${p => p.theme.fontSizeSmall}; font-family: ${p => p.theme.text.familyMono}; caret-color: ${p => p.theme.subText}; ${p => p.hiddenText && 'color: transparent'}; &::selection { background: rgba(0, 0, 0, 0.2); } &::placeholder { color: ${p => p.theme.formPlaceholder}; } [disabled] { color: ${p => p.theme.disabled}; } `; const ActionsBar = styled(ButtonBar)` height: ${space(2)}; margin: ${space(0.5)} 0; `; const VerticalEllipsisIcon = styled(IconEllipsis)` transform: rotate(90deg); `;