You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

UnifiedSearch.vue 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. <!--
  2. - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <NcHeaderMenu id="unified-search"
  24. class="unified-search"
  25. exclude-click-outside-classes="popover"
  26. :open.sync="open"
  27. :aria-label="ariaLabel"
  28. @open="onOpen"
  29. @close="onClose">
  30. <!-- Header icon -->
  31. <template #trigger>
  32. <Magnify class="unified-search__trigger"
  33. :size="22/* fit better next to other 20px icons */"
  34. fill-color="var(--color-primary-text)" />
  35. </template>
  36. <!-- Search form & filters wrapper -->
  37. <div class="unified-search__input-wrapper">
  38. <label for="unified-search__input">{{ ariaLabel }}</label>
  39. <div class="unified-search__input-row">
  40. <form class="unified-search__form"
  41. role="search"
  42. :class="{'icon-loading-small': isLoading}"
  43. @submit.prevent.stop="onInputEnter"
  44. @reset.prevent.stop="onReset">
  45. <!-- Search input -->
  46. <input ref="input"
  47. id="unified-search__input"
  48. v-model="query"
  49. class="unified-search__form-input"
  50. type="search"
  51. :class="{'unified-search__form-input--with-reset': !!query}"
  52. :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
  53. aria-describedby="unified-search-desc"
  54. @input="onInputDebounced"
  55. @keypress.enter.prevent.stop="onInputEnter">
  56. <p id="unified-search-desc" class="hidden-visually">
  57. {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
  58. </p>
  59. <!-- Reset search button -->
  60. <input v-if="!!query && !isLoading"
  61. type="reset"
  62. class="unified-search__form-reset icon-close"
  63. :aria-label="t('core','Reset search')"
  64. value="">
  65. <input v-if="!!query && !isLoading && !enableLiveSearch"
  66. type="submit"
  67. class="unified-search__form-submit icon-confirm"
  68. :aria-label="t('core','Start search')"
  69. value="">
  70. </form>
  71. <!-- Search filters -->
  72. <NcActions v-if="availableFilters.length > 1"
  73. class="unified-search__filters"
  74. placement="bottom"
  75. container=".unified-search__input-wrapper">
  76. <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
  77. <NcActionButton v-for="type in availableFilters"
  78. :key="type"
  79. icon="icon-filter"
  80. :title="t('core', 'Search for {name} only', { name: typesMap[type] })"
  81. @click.stop="onClickFilter(`in:${type}`)">
  82. {{ `in:${type}` }}
  83. </NcActionButton>
  84. </NcActions>
  85. </div>
  86. </div>
  87. <template v-if="!hasResults">
  88. <!-- Loading placeholders -->
  89. <SearchResultPlaceholders v-if="isLoading" />
  90. <NcEmptyContent v-else-if="isValidQuery">
  91. <NcHighlight v-if="triggered" :text="t('core', 'No results for {query}', { query })" :search="query" />
  92. <div v-else>
  93. {{ t('core', 'Press enter to start searching') }}
  94. </div>
  95. <template #icon>
  96. <Magnify />
  97. </template>
  98. </NcEmptyContent>
  99. <NcEmptyContent v-else-if="!isLoading || isShortQuery">
  100. {{ t('core', 'Start typing to search') }}
  101. <template #icon>
  102. <Magnify />
  103. </template>
  104. <template v-if="isShortQuery" #desc>
  105. {{ n('core',
  106. 'Please enter {minSearchLength} character or more to search',
  107. 'Please enter {minSearchLength} characters or more to search',
  108. minSearchLength,
  109. {minSearchLength}) }}
  110. </template>
  111. </NcEmptyContent>
  112. </template>
  113. <!-- Grouped search results -->
  114. <template v-else>
  115. <ul v-for="({list, type}, typesIndex) in orderedResults"
  116. :key="type"
  117. class="unified-search__results"
  118. :class="`unified-search__results-${type}`"
  119. :aria-label="typesMap[type]">
  120. <h2 class="unified-search__results-header">
  121. {{ typesMap[type] }}
  122. </h2>
  123. <!-- Search results -->
  124. <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
  125. <SearchResult v-bind="result"
  126. :query="query"
  127. :focused="focused === 0 && typesIndex === 0 && index === 0"
  128. @focus="setFocusedIndex" />
  129. </li>
  130. <!-- Load more button -->
  131. <li>
  132. <SearchResult v-if="!reached[type]"
  133. class="unified-search__result-more"
  134. :title="loading[type]
  135. ? t('core', 'Loading more results …')
  136. : t('core', 'Load more results')"
  137. :icon-class="loading[type] ? 'icon-loading-small' : ''"
  138. @click.stop="loadMore(type)"
  139. @focus="setFocusedIndex" />
  140. </li>
  141. </ul>
  142. </template>
  143. </NcHeaderMenu>
  144. </template>
  145. <script>
  146. import debounce from 'debounce'
  147. import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
  148. import { showError } from '@nextcloud/dialogs'
  149. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  150. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  151. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  152. import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
  153. import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
  154. import Magnify from 'vue-material-design-icons/Magnify.vue'
  155. import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
  156. import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
  157. import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js'
  158. const REQUEST_FAILED = 0
  159. const REQUEST_OK = 1
  160. const REQUEST_CANCELED = 2
  161. export default {
  162. name: 'UnifiedSearch',
  163. components: {
  164. Magnify,
  165. NcActionButton,
  166. NcActions,
  167. NcEmptyContent,
  168. NcHeaderMenu,
  169. NcHighlight,
  170. SearchResult,
  171. SearchResultPlaceholders,
  172. },
  173. data() {
  174. return {
  175. types: [],
  176. // Cursors per types
  177. cursors: {},
  178. // Various search limits per types
  179. limits: {},
  180. // Loading types
  181. loading: {},
  182. // Reached search types
  183. reached: {},
  184. // Pending cancellable requests
  185. requests: [],
  186. // List of all results
  187. results: {},
  188. query: '',
  189. focused: null,
  190. triggered: false,
  191. defaultLimit,
  192. minSearchLength,
  193. enableLiveSearch,
  194. open: false,
  195. }
  196. },
  197. computed: {
  198. typesIDs() {
  199. return this.types.map(type => type.id)
  200. },
  201. typesNames() {
  202. return this.types.map(type => type.name)
  203. },
  204. typesMap() {
  205. return this.types.reduce((prev, curr) => {
  206. prev[curr.id] = curr.name
  207. return prev
  208. }, {})
  209. },
  210. ariaLabel() {
  211. return t('core', 'Search')
  212. },
  213. /**
  214. * Is there any result to display
  215. *
  216. * @return {boolean}
  217. */
  218. hasResults() {
  219. return Object.keys(this.results).length !== 0
  220. },
  221. /**
  222. * Return ordered results
  223. *
  224. * @return {Array}
  225. */
  226. orderedResults() {
  227. return this.typesIDs
  228. .filter(type => type in this.results)
  229. .map(type => ({
  230. type,
  231. list: this.results[type],
  232. }))
  233. },
  234. /**
  235. * Available filters
  236. * We only show filters that are available on the results
  237. *
  238. * @return {string[]}
  239. */
  240. availableFilters() {
  241. return Object.keys(this.results)
  242. },
  243. /**
  244. * Applied filters
  245. *
  246. * @return {string[]}
  247. */
  248. usedFiltersIn() {
  249. let match
  250. const filters = []
  251. while ((match = regexFilterIn.exec(this.query)) !== null) {
  252. filters.push(match[2])
  253. }
  254. return filters
  255. },
  256. /**
  257. * Applied anti filters
  258. *
  259. * @return {string[]}
  260. */
  261. usedFiltersNot() {
  262. let match
  263. const filters = []
  264. while ((match = regexFilterNot.exec(this.query)) !== null) {
  265. filters.push(match[2])
  266. }
  267. return filters
  268. },
  269. /**
  270. * Is the current search too short
  271. *
  272. * @return {boolean}
  273. */
  274. isShortQuery() {
  275. return this.query && this.query.trim().length < minSearchLength
  276. },
  277. /**
  278. * Is the current search valid
  279. *
  280. * @return {boolean}
  281. */
  282. isValidQuery() {
  283. return this.query && this.query.trim() !== '' && !this.isShortQuery
  284. },
  285. /**
  286. * Have we reached the end of all types searches
  287. *
  288. * @return {boolean}
  289. */
  290. isDoneSearching() {
  291. return Object.values(this.reached).every(state => state === false)
  292. },
  293. /**
  294. * Is there any search in progress
  295. *
  296. * @return {boolean}
  297. */
  298. isLoading() {
  299. return Object.values(this.loading).some(state => state === true)
  300. },
  301. },
  302. async created() {
  303. subscribe('files:navigation:changed', this.resetForm)
  304. this.types = await getTypes()
  305. this.logger.debug('Unified Search initialized with the following providers', this.types)
  306. },
  307. beforeDestroy() {
  308. unsubscribe('files:navigation:changed', this.resetForm)
  309. },
  310. mounted() {
  311. if (OCP.Accessibility.disableKeyboardShortcuts()) {
  312. return
  313. }
  314. document.addEventListener('keydown', (event) => {
  315. // if not already opened, allows us to trigger default browser on second keydown
  316. if (event.ctrlKey && event.key === 'f' && !this.open) {
  317. event.preventDefault()
  318. this.open = true
  319. this.focusInput()
  320. }
  321. // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
  322. if (this.open) {
  323. // If arrow down, focus next result
  324. if (event.key === 'ArrowDown') {
  325. this.focusNext(event)
  326. }
  327. // If arrow up, focus prev result
  328. if (event.key === 'ArrowUp') {
  329. this.focusPrev(event)
  330. }
  331. }
  332. })
  333. },
  334. methods: {
  335. async onOpen() {
  336. this.focusInput()
  337. // Update types list in the background
  338. this.types = await getTypes()
  339. },
  340. onClose() {
  341. emit('nextcloud:unified-search.close')
  342. },
  343. resetForm() {
  344. this.$el.querySelector('form[role="search"]').reset()
  345. },
  346. /**
  347. * Reset the search state
  348. */
  349. onReset() {
  350. emit('nextcloud:unified-search.reset')
  351. this.logger.debug('Search reset')
  352. this.query = ''
  353. this.resetState()
  354. this.focusInput()
  355. },
  356. async resetState() {
  357. this.cursors = {}
  358. this.limits = {}
  359. this.reached = {}
  360. this.results = {}
  361. this.focused = null
  362. this.triggered = false
  363. await this.cancelPendingRequests()
  364. },
  365. /**
  366. * Cancel any ongoing searches
  367. */
  368. async cancelPendingRequests() {
  369. // Cloning so we can keep processing other requests
  370. const requests = this.requests.slice(0)
  371. this.requests = []
  372. // Cancel all pending requests
  373. await Promise.all(requests.map(cancel => cancel()))
  374. },
  375. /**
  376. * Focus the search input on next tick
  377. */
  378. focusInput() {
  379. this.$nextTick(() => {
  380. this.$refs.input.focus()
  381. this.$refs.input.select()
  382. })
  383. },
  384. /**
  385. * If we have results already, open first one
  386. * If not, trigger the search again
  387. */
  388. onInputEnter() {
  389. if (this.hasResults) {
  390. const results = this.getResultsList()
  391. results[0].click()
  392. return
  393. }
  394. this.onInput()
  395. },
  396. /**
  397. * Start searching on input
  398. */
  399. async onInput() {
  400. // emit the search query
  401. emit('nextcloud:unified-search.search', { query: this.query })
  402. // Do not search if not long enough
  403. if (this.query.trim() === '' || this.isShortQuery) {
  404. for (const type of this.typesIDs) {
  405. this.$delete(this.results, type)
  406. }
  407. return
  408. }
  409. let types = this.typesIDs
  410. let query = this.query
  411. // Filter out types
  412. if (this.usedFiltersNot.length > 0) {
  413. types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
  414. }
  415. // Only use those filters if any and check if they are valid
  416. if (this.usedFiltersIn.length > 0) {
  417. types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
  418. }
  419. // Remove any filters from the query
  420. query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
  421. // Reset search if the query changed
  422. await this.resetState()
  423. this.triggered = true
  424. if (!types.length) {
  425. // no results since no types were selected
  426. this.logger.error('No types to search in')
  427. return
  428. }
  429. this.$set(this.loading, 'all', true)
  430. this.logger.debug(`Searching ${query} in`, types)
  431. Promise.all(types.map(async type => {
  432. try {
  433. // Init cancellable request
  434. const { request, cancel } = search({ type, query })
  435. this.requests.push(cancel)
  436. // Fetch results
  437. const { data } = await request()
  438. // Process results
  439. if (data.ocs.data.entries.length > 0) {
  440. this.$set(this.results, type, data.ocs.data.entries)
  441. } else {
  442. this.$delete(this.results, type)
  443. }
  444. // Save cursor if any
  445. if (data.ocs.data.cursor) {
  446. this.$set(this.cursors, type, data.ocs.data.cursor)
  447. } else if (!data.ocs.data.isPaginated) {
  448. // If no cursor and no pagination, we save the default amount
  449. // provided by server's initial state `defaultLimit`
  450. this.$set(this.limits, type, this.defaultLimit)
  451. }
  452. // Check if we reached end of pagination
  453. if (data.ocs.data.entries.length < this.defaultLimit) {
  454. this.$set(this.reached, type, true)
  455. }
  456. // If none already focused, focus the first rendered result
  457. if (this.focused === null) {
  458. this.focused = 0
  459. }
  460. return REQUEST_OK
  461. } catch (error) {
  462. this.$delete(this.results, type)
  463. // If this is not a cancelled throw
  464. if (error.response && error.response.status) {
  465. this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
  466. showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
  467. return REQUEST_FAILED
  468. }
  469. return REQUEST_CANCELED
  470. }
  471. })).then(results => {
  472. // Do not declare loading finished if the request have been cancelled
  473. // This means another search was triggered and we're therefore still loading
  474. if (results.some(result => result === REQUEST_CANCELED)) {
  475. return
  476. }
  477. // We finished all searches
  478. this.loading = {}
  479. })
  480. },
  481. onInputDebounced: enableLiveSearch
  482. ? debounce(function(e) {
  483. this.onInput(e)
  484. }, 500)
  485. : function() {
  486. this.triggered = false
  487. },
  488. /**
  489. * Load more results for the provided type
  490. *
  491. * @param {string} type type
  492. */
  493. async loadMore(type) {
  494. // If already loading, ignore
  495. if (this.loading[type]) {
  496. return
  497. }
  498. if (this.cursors[type]) {
  499. // Init cancellable request
  500. const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
  501. this.requests.push(cancel)
  502. // Fetch results
  503. const { data } = await request()
  504. // Save cursor if any
  505. if (data.ocs.data.cursor) {
  506. this.$set(this.cursors, type, data.ocs.data.cursor)
  507. }
  508. // Process results
  509. if (data.ocs.data.entries.length > 0) {
  510. this.results[type].push(...data.ocs.data.entries)
  511. }
  512. // Check if we reached end of pagination
  513. if (data.ocs.data.entries.length < this.defaultLimit) {
  514. this.$set(this.reached, type, true)
  515. }
  516. } else
  517. // If no cursor, we might have all the results already,
  518. // let's fake pagination and show the next xxx entries
  519. if (this.limits[type] && this.limits[type] >= 0) {
  520. this.limits[type] += this.defaultLimit
  521. // Check if we reached end of pagination
  522. if (this.limits[type] >= this.results[type].length) {
  523. this.$set(this.reached, type, true)
  524. }
  525. }
  526. // Focus result after render
  527. if (this.focused !== null) {
  528. this.$nextTick(() => {
  529. this.focusIndex(this.focused)
  530. })
  531. }
  532. },
  533. /**
  534. * Return a subset of the array if the search provider
  535. * doesn't supports pagination
  536. *
  537. * @param {Array} list the results
  538. * @param {string} type the type
  539. * @return {Array}
  540. */
  541. limitIfAny(list, type) {
  542. if (type in this.limits) {
  543. return list.slice(0, this.limits[type])
  544. }
  545. return list
  546. },
  547. getResultsList() {
  548. return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
  549. },
  550. /**
  551. * Focus the first result if any
  552. *
  553. * @param {Event} event the keydown event
  554. */
  555. focusFirst(event) {
  556. const results = this.getResultsList()
  557. if (results && results.length > 0) {
  558. if (event) {
  559. event.preventDefault()
  560. }
  561. this.focused = 0
  562. this.focusIndex(this.focused)
  563. }
  564. },
  565. /**
  566. * Focus the next result if any
  567. *
  568. * @param {Event} event the keydown event
  569. */
  570. focusNext(event) {
  571. if (this.focused === null) {
  572. this.focusFirst(event)
  573. return
  574. }
  575. const results = this.getResultsList()
  576. // If we're not focusing the last, focus the next one
  577. if (results && results.length > 0 && this.focused + 1 < results.length) {
  578. event.preventDefault()
  579. this.focused++
  580. this.focusIndex(this.focused)
  581. }
  582. },
  583. /**
  584. * Focus the previous result if any
  585. *
  586. * @param {Event} event the keydown event
  587. */
  588. focusPrev(event) {
  589. if (this.focused === null) {
  590. this.focusFirst(event)
  591. return
  592. }
  593. const results = this.getResultsList()
  594. // If we're not focusing the first, focus the previous one
  595. if (results && results.length > 0 && this.focused > 0) {
  596. event.preventDefault()
  597. this.focused--
  598. this.focusIndex(this.focused)
  599. }
  600. },
  601. /**
  602. * Focus the specified result index if it exists
  603. *
  604. * @param {number} index the result index
  605. */
  606. focusIndex(index) {
  607. const results = this.getResultsList()
  608. if (results && results[index]) {
  609. results[index].focus()
  610. }
  611. },
  612. /**
  613. * Set the current focused element based on the target
  614. *
  615. * @param {Event} event the focus event
  616. */
  617. setFocusedIndex(event) {
  618. const entry = event.target
  619. const results = this.getResultsList()
  620. const index = [...results].findIndex(search => search === entry)
  621. if (index > -1) {
  622. // let's not use focusIndex as the entry is already focused
  623. this.focused = index
  624. }
  625. },
  626. onClickFilter(filter) {
  627. this.query = `${this.query} ${filter}`
  628. .replace(/ {2}/g, ' ')
  629. .trim()
  630. this.onInput()
  631. },
  632. },
  633. }
  634. </script>
  635. <style lang="scss" scoped>
  636. @use "sass:math";
  637. $margin: 10px;
  638. $input-height: 34px;
  639. $input-padding: 6px;
  640. .unified-search {
  641. &__trigger {
  642. filter: var(--background-image-invert-if-bright);
  643. }
  644. &__input-wrapper {
  645. position: sticky;
  646. // above search results
  647. z-index: 2;
  648. top: 0;
  649. display: inline-flex;
  650. flex-direction: column;
  651. align-items: center;
  652. width: 100%;
  653. background-color: var(--color-main-background);
  654. label[for="unified-search__input"] {
  655. align-self: flex-start;
  656. font-weight: bold;
  657. font-size: 19px;
  658. margin-left: 13px;
  659. }
  660. }
  661. &__form-input {
  662. margin: 0 !important;
  663. }
  664. &__input-row {
  665. display: flex;
  666. width: 100%;
  667. align-items: center;
  668. }
  669. &__filters {
  670. margin: $margin 0 $margin math.div($margin, 2);
  671. ul {
  672. display: inline-flex;
  673. justify-content: space-between;
  674. }
  675. }
  676. &__form {
  677. position: relative;
  678. width: 100%;
  679. margin: $margin 0;
  680. // Loading spinner
  681. &::after {
  682. right: $input-padding;
  683. left: auto;
  684. }
  685. &-input,
  686. &-reset {
  687. margin: math.div($input-padding, 2);
  688. }
  689. &-input {
  690. width: 100%;
  691. height: $input-height;
  692. padding: $input-padding;
  693. &,
  694. &[placeholder],
  695. &::placeholder {
  696. overflow: hidden;
  697. white-space: nowrap;
  698. text-overflow: ellipsis;
  699. }
  700. // Hide webkit clear search
  701. &::-webkit-search-decoration,
  702. &::-webkit-search-cancel-button,
  703. &::-webkit-search-results-button,
  704. &::-webkit-search-results-decoration {
  705. -webkit-appearance: none;
  706. }
  707. // Ellipsis earlier if reset button is here
  708. .icon-loading-small &,
  709. &--with-reset {
  710. padding-right: $input-height;
  711. }
  712. }
  713. &-reset, &-submit {
  714. position: absolute;
  715. top: 0;
  716. right: 4px;
  717. width: $input-height - $input-padding;
  718. height: $input-height - $input-padding;
  719. min-height: 30px;
  720. padding: 0;
  721. opacity: .5;
  722. border: none;
  723. background-color: transparent;
  724. margin-right: 0;
  725. &:hover,
  726. &:focus,
  727. &:active {
  728. opacity: 1;
  729. }
  730. }
  731. &-submit {
  732. right: 28px;
  733. }
  734. }
  735. &__results {
  736. &-header {
  737. display: block;
  738. margin: $margin;
  739. margin-bottom: $margin - 4px;
  740. margin-left: 13px;
  741. color: var(--color-primary-element);
  742. font-size: 19px;
  743. font-weight: bold;
  744. }
  745. display: flex;
  746. flex-direction: column;
  747. gap: 4px;
  748. }
  749. .unified-search__result-more::v-deep {
  750. color: var(--color-text-maxcontrast);
  751. }
  752. .empty-content {
  753. margin: 10vh 0;
  754. ::v-deep .empty-content__title {
  755. font-weight: normal;
  756. font-size: var(--default-font-size);
  757. padding: 0 15px;
  758. text-align: center;
  759. }
  760. }
  761. }
  762. </style>