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

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