searchDropdown.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import {Fragment, PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import LoadingIndicator from 'app/components/loadingIndicator';
  4. import {t} from 'app/locale';
  5. import overflowEllipsis from 'app/styles/overflowEllipsis';
  6. import space from 'app/styles/space';
  7. import {SearchGroup, SearchItem} from './types';
  8. type Props = {
  9. className?: string;
  10. items: SearchGroup[];
  11. searchSubstring: string;
  12. onClick: (value: string, item: SearchItem) => void;
  13. loading: boolean;
  14. };
  15. class SearchDropdown extends PureComponent<Props> {
  16. static defaultProps = {
  17. searchSubstring: '',
  18. onClick: function () {},
  19. };
  20. renderDescription = (item: SearchItem) => {
  21. const searchSubstring = this.props.searchSubstring;
  22. if (!searchSubstring) {
  23. return item.desc;
  24. }
  25. const text = item.desc;
  26. if (!text) {
  27. return null;
  28. }
  29. const idx = text.toLowerCase().indexOf(searchSubstring.toLowerCase());
  30. if (idx === -1) {
  31. return item.desc;
  32. }
  33. return (
  34. <span>
  35. {text.substr(0, idx)}
  36. <strong>{text.substr(idx, searchSubstring.length)}</strong>
  37. {text.substr(idx + searchSubstring.length)}
  38. </span>
  39. );
  40. };
  41. renderHeaderItem = (item: SearchGroup) => (
  42. <SearchDropdownGroup key={item.title}>
  43. <SearchDropdownGroupTitle>
  44. {item.icon}
  45. {item.title && item.title}
  46. {item.desc && <span>{item.desc}</span>}
  47. </SearchDropdownGroupTitle>
  48. </SearchDropdownGroup>
  49. );
  50. renderItem = (item: SearchItem) => (
  51. <SearchListItem
  52. key={item.value || item.desc}
  53. className={item.active ? 'active' : undefined}
  54. data-test-id="search-autocomplete-item"
  55. onClick={this.props.onClick.bind(this, item.value, item)}
  56. ref={element => item.active && element?.scrollIntoView?.({block: 'nearest'})}
  57. >
  58. <SearchItemTitleWrapper>
  59. {item.title && item.title + ' · '}
  60. <Description>{this.renderDescription(item)}</Description>
  61. </SearchItemTitleWrapper>
  62. </SearchListItem>
  63. );
  64. render() {
  65. const {className, loading, items} = this.props;
  66. return (
  67. <StyledSearchDropdown className={className}>
  68. {loading ? (
  69. <LoadingWrapper key="loading" data-test-id="search-autocomplete-loading">
  70. <LoadingIndicator mini />
  71. </LoadingWrapper>
  72. ) : (
  73. <SearchItemsList>
  74. {items.map(item => {
  75. const isEmpty = item.children && !item.children.length;
  76. const invalidTag = item.type === 'invalid-tag';
  77. // Hide header if `item.children` is defined, an array, and is empty
  78. return (
  79. <Fragment key={item.title}>
  80. {invalidTag && <Info>{t('Invalid tag')}</Info>}
  81. {item.type === 'header' && this.renderHeaderItem(item)}
  82. {item.children && item.children.map(this.renderItem)}
  83. {isEmpty && !invalidTag && <Info>{t('No items found')}</Info>}
  84. </Fragment>
  85. );
  86. })}
  87. </SearchItemsList>
  88. )}
  89. </StyledSearchDropdown>
  90. );
  91. }
  92. }
  93. export default SearchDropdown;
  94. const StyledSearchDropdown = styled('div')`
  95. /* Container has a border that we need to account for */
  96. position: absolute;
  97. top: 100%;
  98. left: -1px;
  99. right: -1px;
  100. z-index: ${p => p.theme.zIndex.dropdown};
  101. overflow: hidden;
  102. background: ${p => p.theme.background};
  103. box-shadow: ${p => p.theme.dropShadowLight};
  104. border: 1px solid ${p => p.theme.border};
  105. border-radius: ${p => p.theme.borderRadiusBottom};
  106. `;
  107. const LoadingWrapper = styled('div')`
  108. display: flex;
  109. justify-content: center;
  110. padding: ${space(1)};
  111. `;
  112. const Info = styled('div')`
  113. display: flex;
  114. padding: ${space(1)} ${space(2)};
  115. font-size: ${p => p.theme.fontSizeLarge};
  116. color: ${p => p.theme.gray300};
  117. &:not(:last-child) {
  118. border-bottom: 1px solid ${p => p.theme.innerBorder};
  119. }
  120. `;
  121. const ListItem = styled('li')`
  122. &:not(:last-child) {
  123. border-bottom: 1px solid ${p => p.theme.innerBorder};
  124. }
  125. `;
  126. const SearchDropdownGroup = styled(ListItem)``;
  127. const SearchDropdownGroupTitle = styled('header')`
  128. display: flex;
  129. align-items: center;
  130. background-color: ${p => p.theme.backgroundSecondary};
  131. color: ${p => p.theme.gray300};
  132. font-weight: normal;
  133. font-size: ${p => p.theme.fontSizeMedium};
  134. margin: 0;
  135. padding: ${space(1)} ${space(2)};
  136. & > svg {
  137. margin-right: ${space(1)};
  138. }
  139. `;
  140. const SearchItemsList = styled('ul')`
  141. padding-left: 0;
  142. list-style: none;
  143. margin-bottom: 0;
  144. `;
  145. const SearchListItem = styled(ListItem)`
  146. scroll-margin: 40px 0;
  147. font-size: ${p => p.theme.fontSizeLarge};
  148. padding: ${space(1)} ${space(2)};
  149. cursor: pointer;
  150. &:hover,
  151. &.active {
  152. background: ${p => p.theme.focus};
  153. }
  154. `;
  155. const SearchItemTitleWrapper = styled('div')`
  156. color: ${p => p.theme.textColor};
  157. font-weight: normal;
  158. font-size: ${p => p.theme.fontSizeMedium};
  159. margin: 0;
  160. line-height: ${p => p.theme.text.lineHeightHeading};
  161. ${overflowEllipsis};
  162. `;
  163. const Description = styled('span')`
  164. font-size: ${p => p.theme.fontSizeSmall};
  165. font-family: ${p => p.theme.text.familyMono};
  166. `;