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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  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>
  88. <h2 :key="type" class="unified-search__results-header">
  89. {{ typesMap[type] }}
  90. </h2>
  91. <ul :key="type"
  92. class="unified-search__results"
  93. :class="`unified-search__results-${type}`"
  94. :aria-label="typesMap[type]">
  95. <!-- Search results -->
  96. <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
  97. <SearchResult v-bind="result"
  98. :query="query"
  99. :focused="focused === 0 && typesIndex === 0 && index === 0"
  100. @focus="setFocusedIndex" />
  101. </li>
  102. <!-- Load more button -->
  103. <li>
  104. <SearchResult v-if="!reached[type]"
  105. class="unified-search__result-more"
  106. :title="loading[type]
  107. ? t('core', 'Loading more results …')
  108. : t('core', 'Load more results')"
  109. :icon-class="loading[type] ? 'icon-loading-small' : ''"
  110. @click.prevent.stop="loadMore(type)"
  111. @focus="setFocusedIndex" />
  112. </li>
  113. </ul>
  114. </template>
  115. </NcHeaderMenu>
  116. </template>
  117. <script>
  118. import debounce from 'debounce'
  119. import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
  120. import { showError } from '@nextcloud/dialogs'
  121. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  122. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  123. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  124. import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
  125. import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
  126. import Magnify from 'vue-material-design-icons/Magnify.vue'
  127. import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
  128. import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
  129. import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js'
  130. const REQUEST_FAILED = 0
  131. const REQUEST_OK = 1
  132. const REQUEST_CANCELED = 2
  133. export default {
  134. name: 'UnifiedSearch',
  135. components: {
  136. Magnify,
  137. NcActionButton,
  138. NcActions,
  139. NcEmptyContent,
  140. NcHeaderMenu,
  141. SearchResult,
  142. SearchResultPlaceholders,
  143. NcTextField,
  144. },
  145. data() {
  146. return {
  147. types: [],
  148. // Cursors per types
  149. cursors: {},
  150. // Various search limits per types
  151. limits: {},
  152. // Loading types
  153. loading: {},
  154. // Reached search types
  155. reached: {},
  156. // Pending cancellable requests
  157. requests: [],
  158. // List of all results
  159. results: {},
  160. query: '',
  161. focused: null,
  162. triggered: false,
  163. defaultLimit,
  164. minSearchLength,
  165. enableLiveSearch,
  166. open: false,
  167. }
  168. },
  169. computed: {
  170. typesIDs() {
  171. return this.types.map(type => type.id)
  172. },
  173. typesNames() {
  174. return this.types.map(type => type.name)
  175. },
  176. typesMap() {
  177. return this.types.reduce((prev, curr) => {
  178. prev[curr.id] = curr.name
  179. return prev
  180. }, {})
  181. },
  182. ariaLabel() {
  183. return t('core', 'Search')
  184. },
  185. /**
  186. * Is there any result to display
  187. *
  188. * @return {boolean}
  189. */
  190. hasResults() {
  191. return Object.keys(this.results).length !== 0
  192. },
  193. /**
  194. * Return ordered results
  195. *
  196. * @return {Array}
  197. */
  198. orderedResults() {
  199. return this.typesIDs
  200. .filter(type => type in this.results)
  201. .map(type => ({
  202. type,
  203. list: this.results[type],
  204. }))
  205. },
  206. /**
  207. * Available filters
  208. * We only show filters that are available on the results
  209. *
  210. * @return {string[]}
  211. */
  212. availableFilters() {
  213. return Object.keys(this.results)
  214. },
  215. /**
  216. * Applied filters
  217. *
  218. * @return {string[]}
  219. */
  220. usedFiltersIn() {
  221. let match
  222. const filters = []
  223. while ((match = regexFilterIn.exec(this.query)) !== null) {
  224. filters.push(match[2])
  225. }
  226. return filters
  227. },
  228. /**
  229. * Applied anti filters
  230. *
  231. * @return {string[]}
  232. */
  233. usedFiltersNot() {
  234. let match
  235. const filters = []
  236. while ((match = regexFilterNot.exec(this.query)) !== null) {
  237. filters.push(match[2])
  238. }
  239. return filters
  240. },
  241. /**
  242. * Valid query empty content title
  243. *
  244. * @return {string}
  245. */
  246. validQueryTitle() {
  247. return this.triggered
  248. ? t('core', 'No results for {query}', { query: this.query })
  249. : t('core', 'Press Enter to start searching')
  250. },
  251. /**
  252. * Short query empty content description
  253. *
  254. * @return {string}
  255. */
  256. shortQueryDescription() {
  257. if (!this.isShortQuery) {
  258. return ''
  259. }
  260. return n('core',
  261. 'Please enter {minSearchLength} character or more to search',
  262. 'Please enter {minSearchLength} characters or more to search',
  263. this.minSearchLength,
  264. { minSearchLength: this.minSearchLength })
  265. },
  266. /**
  267. * Is the current search too short
  268. *
  269. * @return {boolean}
  270. */
  271. isShortQuery() {
  272. return this.query && this.query.trim().length < minSearchLength
  273. },
  274. /**
  275. * Is the current search valid
  276. *
  277. * @return {boolean}
  278. */
  279. isValidQuery() {
  280. return this.query && this.query.trim() !== '' && !this.isShortQuery
  281. },
  282. /**
  283. * Have we reached the end of all types searches
  284. *
  285. * @return {boolean}
  286. */
  287. isDoneSearching() {
  288. return Object.values(this.reached).every(state => state === false)
  289. },
  290. /**
  291. * Is there any search in progress
  292. *
  293. * @return {boolean}
  294. */
  295. isLoading() {
  296. return Object.values(this.loading).some(state => state === true)
  297. },
  298. },
  299. async created() {
  300. this.types = await getTypes()
  301. this.logger.debug('Unified Search initialized with the following providers', this.types)
  302. },
  303. beforeDestroy() {
  304. unsubscribe('files:navigation:changed', this.onNavigationChange)
  305. },
  306. mounted() {
  307. // subscribe in mounted, as onNavigationChange relys on $el
  308. subscribe('files:navigation:changed', this.onNavigationChange)
  309. if (OCP.Accessibility.disableKeyboardShortcuts()) {
  310. return
  311. }
  312. document.addEventListener('keydown', (event) => {
  313. // if not already opened, allows us to trigger default browser on second keydown
  314. if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
  315. event.preventDefault()
  316. this.open = true
  317. } else if (event.ctrlKey && event.key === 'f' && this.open) {
  318. // User wants to use the native browser search, so we close ours again
  319. this.open = false
  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. // Update types list in the background
  337. this.types = await getTypes()
  338. },
  339. onClose() {
  340. emit('nextcloud:unified-search.close')
  341. },
  342. onNavigationChange() {
  343. this.$el?.querySelector?.('form[role="search"]')?.reset?.()
  344. },
  345. /**
  346. * Reset the search state
  347. */
  348. onReset() {
  349. emit('nextcloud:unified-search.reset')
  350. this.logger.debug('Search reset')
  351. this.query = ''
  352. this.resetState()
  353. this.focusInput()
  354. },
  355. async resetState() {
  356. this.cursors = {}
  357. this.limits = {}
  358. this.reached = {}
  359. this.results = {}
  360. this.focused = null
  361. this.triggered = false
  362. await this.cancelPendingRequests()
  363. },
  364. /**
  365. * Cancel any ongoing searches
  366. */
  367. async cancelPendingRequests() {
  368. // Cloning so we can keep processing other requests
  369. const requests = this.requests.slice(0)
  370. this.requests = []
  371. // Cancel all pending requests
  372. await Promise.all(requests.map(cancel => cancel()))
  373. },
  374. /**
  375. * Focus the search input on next tick
  376. */
  377. focusInput() {
  378. this.$nextTick(() => {
  379. this.$refs.input.focus()
  380. this.$refs.input.select()
  381. })
  382. },
  383. /**
  384. * If we have results already, open first one
  385. * If not, trigger the search again
  386. */
  387. onInputEnter() {
  388. if (this.hasResults) {
  389. const results = this.getResultsList()
  390. results[0].click()
  391. return
  392. }
  393. this.onInput()
  394. },
  395. /**
  396. * Start searching on input
  397. */
  398. async onInput() {
  399. // emit the search query
  400. emit('nextcloud:unified-search.search', { query: this.query })
  401. // Do not search if not long enough
  402. if (this.query.trim() === '' || this.isShortQuery) {
  403. for (const type of this.typesIDs) {
  404. this.$delete(this.results, type)
  405. }
  406. return
  407. }
  408. let types = this.typesIDs
  409. let query = this.query
  410. // Filter out types
  411. if (this.usedFiltersNot.length > 0) {
  412. types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
  413. }
  414. // Only use those filters if any and check if they are valid
  415. if (this.usedFiltersIn.length > 0) {
  416. types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
  417. }
  418. // Remove any filters from the query
  419. query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
  420. // Reset search if the query changed
  421. await this.resetState()
  422. this.triggered = true
  423. if (!types.length) {
  424. // no results since no types were selected
  425. this.logger.error('No types to search in')
  426. return
  427. }
  428. this.$set(this.loading, 'all', true)
  429. this.logger.debug(`Searching ${query} in`, types)
  430. Promise.all(types.map(async type => {
  431. try {
  432. // Init cancellable request
  433. const { request, cancel } = search({ type, query })
  434. this.requests.push(cancel)
  435. // Fetch results
  436. const { data } = await request()
  437. // Process results
  438. if (data.ocs.data.entries.length > 0) {
  439. this.$set(this.results, type, data.ocs.data.entries)
  440. } else {
  441. this.$delete(this.results, type)
  442. }
  443. // Save cursor if any
  444. if (data.ocs.data.cursor) {
  445. this.$set(this.cursors, type, data.ocs.data.cursor)
  446. } else if (!data.ocs.data.isPaginated) {
  447. // If no cursor and no pagination, we save the default amount
  448. // provided by server's initial state `defaultLimit`
  449. this.$set(this.limits, type, this.defaultLimit)
  450. }
  451. // Check if we reached end of pagination
  452. if (data.ocs.data.entries.length < this.defaultLimit) {
  453. this.$set(this.reached, type, true)
  454. }
  455. // If none already focused, focus the first rendered result
  456. if (this.focused === null) {
  457. this.focused = 0
  458. }
  459. return REQUEST_OK
  460. } catch (error) {
  461. this.$delete(this.results, type)
  462. // If this is not a cancelled throw
  463. if (error.response && error.response.status) {
  464. this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
  465. showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
  466. return REQUEST_FAILED
  467. }
  468. return REQUEST_CANCELED
  469. }
  470. })).then(results => {
  471. // Do not declare loading finished if the request have been cancelled
  472. // This means another search was triggered and we're therefore still loading
  473. if (results.some(result => result === REQUEST_CANCELED)) {
  474. return
  475. }
  476. // We finished all searches
  477. this.loading = {}
  478. })
  479. },
  480. onInputDebounced: enableLiveSearch
  481. ? debounce(function(e) {
  482. this.onInput(e)
  483. }, 500)
  484. : function() {
  485. this.triggered = false
  486. },
  487. /**
  488. * Load more results for the provided type
  489. *
  490. * @param {string} type type
  491. */
  492. async loadMore(type) {
  493. // If already loading, ignore
  494. if (this.loading[type]) {
  495. return
  496. }
  497. if (this.cursors[type]) {
  498. // Init cancellable request
  499. const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
  500. this.requests.push(cancel)
  501. // Fetch results
  502. const { data } = await request()
  503. // Save cursor if any
  504. if (data.ocs.data.cursor) {
  505. this.$set(this.cursors, type, data.ocs.data.cursor)
  506. }
  507. // Process results
  508. if (data.ocs.data.entries.length > 0) {
  509. this.results[type].push(...data.ocs.data.entries)
  510. }
  511. // Check if we reached end of pagination
  512. if (data.ocs.data.entries.length < this.defaultLimit) {
  513. this.$set(this.reached, type, true)
  514. }
  515. } else {
  516. // If no cursor, we might have all the results already,
  517. // let's fake pagination and show the next xxx entries
  518. if (this.limits[type] && this.limits[type] >= 0) {
  519. this.limits[type] += this.defaultLimit
  520. // Check if we reached end of pagination
  521. if (this.limits[type] >= this.results[type].length) {
  522. this.$set(this.reached, type, true)
  523. }
  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: 10px;
  640. .unified-search {
  641. &__input-wrapper {
  642. position: sticky;
  643. // above search results
  644. z-index: 2;
  645. top: 0;
  646. display: inline-flex;
  647. flex-direction: column;
  648. align-items: center;
  649. width: 100%;
  650. background-color: var(--color-main-background);
  651. label[for="unified-search__input"] {
  652. align-self: flex-start;
  653. font-weight: bold;
  654. font-size: 19px;
  655. margin-left: 13px;
  656. }
  657. }
  658. &__form-input {
  659. margin: 0 !important;
  660. &:focus,
  661. &:focus-visible,
  662. &:active {
  663. border-color: 2px solid var(--color-main-text) !important;
  664. box-shadow: 0 0 0 2px var(--color-main-background) !important;
  665. }
  666. }
  667. &__input-row {
  668. display: flex;
  669. width: 100%;
  670. align-items: center;
  671. }
  672. &__filters {
  673. margin: $margin 0 $margin math.div($margin, 2);
  674. padding-top: 5px;
  675. ul {
  676. display: inline-flex;
  677. justify-content: space-between;
  678. }
  679. }
  680. &__form {
  681. position: relative;
  682. width: 100%;
  683. margin: $margin 0;
  684. // Loading spinner
  685. &::after {
  686. right: $input-padding;
  687. left: auto;
  688. }
  689. &-input,
  690. &-reset {
  691. margin: math.div($input-padding, 2);
  692. }
  693. &-input {
  694. width: 100%;
  695. height: $input-height;
  696. padding: $input-padding;
  697. &,
  698. &[placeholder],
  699. &::placeholder {
  700. overflow: hidden;
  701. white-space: nowrap;
  702. text-overflow: ellipsis;
  703. }
  704. // Hide webkit clear search
  705. &::-webkit-search-decoration,
  706. &::-webkit-search-cancel-button,
  707. &::-webkit-search-results-button,
  708. &::-webkit-search-results-decoration {
  709. -webkit-appearance: none;
  710. }
  711. }
  712. &-reset, &-submit {
  713. position: absolute;
  714. top: 0;
  715. right: 4px;
  716. width: $input-height - $input-padding;
  717. height: $input-height - $input-padding;
  718. min-height: 30px;
  719. padding: 0;
  720. opacity: .5;
  721. border: none;
  722. background-color: transparent;
  723. margin-right: 0;
  724. &:hover,
  725. &:focus,
  726. &:active {
  727. opacity: 1;
  728. }
  729. }
  730. &-submit {
  731. right: 28px;
  732. }
  733. }
  734. &__results {
  735. &-header {
  736. display: block;
  737. margin: $margin;
  738. margin-bottom: $margin - 4px;
  739. margin-left: 13px;
  740. color: var(--color-primary-element);
  741. font-size: 19px;
  742. font-weight: bold;
  743. }
  744. display: flex;
  745. flex-direction: column;
  746. gap: 4px;
  747. }
  748. .unified-search__result-more::v-deep {
  749. color: var(--color-text-maxcontrast);
  750. }
  751. .empty-content {
  752. margin: 10vh 0;
  753. ::v-deep .empty-content__title {
  754. font-weight: normal;
  755. font-size: var(--default-font-size);
  756. text-align: center;
  757. }
  758. }
  759. }
  760. </style>