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.
848 lines
20 KiB
848 lines
20 KiB
<!--
|
|
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
-->
|
|
<template>
|
|
<NcHeaderMenu id="unified-search"
|
|
class="unified-search"
|
|
:exclude-click-outside-selectors="['.popover']"
|
|
:open.sync="open"
|
|
:aria-label="ariaLabel"
|
|
@open="onOpen"
|
|
@close="onClose">
|
|
<!-- Header icon -->
|
|
<template #trigger>
|
|
<Magnify class="unified-search__trigger-icon" :size="20" />
|
|
</template>
|
|
|
|
<!-- Search form & filters wrapper -->
|
|
<div class="unified-search__input-wrapper">
|
|
<div class="unified-search__input-row">
|
|
<NcTextField ref="input"
|
|
:value.sync="query"
|
|
trailing-button-icon="close"
|
|
:label="ariaLabel"
|
|
:trailing-button-label="t('core','Reset search')"
|
|
:show-trailing-button="query !== ''"
|
|
aria-describedby="unified-search-desc"
|
|
class="unified-search__form-input"
|
|
:class="{'unified-search__form-input--with-reset': !!query}"
|
|
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
|
|
@trailing-button-click="onReset"
|
|
@input="onInputDebounced" />
|
|
<p id="unified-search-desc" class="hidden-visually">
|
|
{{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
|
|
</p>
|
|
|
|
<!-- Search filters -->
|
|
<NcActions v-if="availableFilters.length > 1"
|
|
class="unified-search__filters"
|
|
placement="bottom-end"
|
|
container=".unified-search__input-wrapper">
|
|
<!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
|
|
<NcActionButton v-for="filter in availableFilters"
|
|
:key="filter"
|
|
icon="icon-filter"
|
|
@click.stop="onClickFilter(`in:${filter}`)">
|
|
{{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
|
|
</NcActionButton>
|
|
</NcActions>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="!hasResults">
|
|
<!-- Loading placeholders -->
|
|
<SearchResultPlaceholders v-if="isLoading" />
|
|
|
|
<NcEmptyContent v-else-if="isValidQuery"
|
|
:title="validQueryTitle">
|
|
<template #icon>
|
|
<Magnify />
|
|
</template>
|
|
</NcEmptyContent>
|
|
|
|
<NcEmptyContent v-else-if="!isLoading || isShortQuery"
|
|
:title="t('core', 'Start typing to search')"
|
|
:description="shortQueryDescription">
|
|
<template #icon>
|
|
<Magnify />
|
|
</template>
|
|
</NcEmptyContent>
|
|
</template>
|
|
|
|
<!-- Grouped search results -->
|
|
<template v-for="({list, type}, typesIndex) in orderedResults" v-else>
|
|
<h2 :key="type" class="unified-search__results-header">
|
|
{{ typesMap[type] }}
|
|
</h2>
|
|
<ul :key="type"
|
|
class="unified-search__results"
|
|
:class="`unified-search__results-${type}`"
|
|
:aria-label="typesMap[type]">
|
|
<!-- Search results -->
|
|
<li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
|
|
<SearchResult v-bind="result"
|
|
:query="query"
|
|
:focused="focused === 0 && typesIndex === 0 && index === 0"
|
|
@focus="setFocusedIndex" />
|
|
</li>
|
|
|
|
<!-- Load more button -->
|
|
<li>
|
|
<SearchResult v-if="!reached[type]"
|
|
class="unified-search__result-more"
|
|
:title="loading[type]
|
|
? t('core', 'Loading more results …')
|
|
: t('core', 'Load more results')"
|
|
:icon-class="loading[type] ? 'icon-loading-small' : ''"
|
|
@click.prevent.stop="loadMore(type)"
|
|
@focus="setFocusedIndex" />
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</NcHeaderMenu>
|
|
</template>
|
|
|
|
<script>
|
|
import debounce from 'debounce'
|
|
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
|
import { showError } from '@nextcloud/dialogs'
|
|
|
|
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
|
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
|
|
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
|
|
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
|
|
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
|
|
|
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
|
|
|
import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
|
|
import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
|
|
|
|
import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js'
|
|
|
|
const REQUEST_FAILED = 0
|
|
const REQUEST_OK = 1
|
|
const REQUEST_CANCELED = 2
|
|
|
|
export default {
|
|
name: 'LegacyUnifiedSearch',
|
|
|
|
components: {
|
|
Magnify,
|
|
NcActionButton,
|
|
NcActions,
|
|
NcEmptyContent,
|
|
NcHeaderMenu,
|
|
SearchResult,
|
|
SearchResultPlaceholders,
|
|
NcTextField,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
types: [],
|
|
|
|
// Cursors per types
|
|
cursors: {},
|
|
// Various search limits per types
|
|
limits: {},
|
|
// Loading types
|
|
loading: {},
|
|
// Reached search types
|
|
reached: {},
|
|
// Pending cancellable requests
|
|
requests: [],
|
|
// List of all results
|
|
results: {},
|
|
|
|
query: '',
|
|
focused: null,
|
|
triggered: false,
|
|
|
|
defaultLimit,
|
|
minSearchLength,
|
|
enableLiveSearch,
|
|
|
|
open: false,
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
typesIDs() {
|
|
return this.types.map(type => type.id)
|
|
},
|
|
typesNames() {
|
|
return this.types.map(type => type.name)
|
|
},
|
|
typesMap() {
|
|
return this.types.reduce((prev, curr) => {
|
|
prev[curr.id] = curr.name
|
|
return prev
|
|
}, {})
|
|
},
|
|
|
|
ariaLabel() {
|
|
return t('core', 'Search')
|
|
},
|
|
|
|
/**
|
|
* Is there any result to display
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
hasResults() {
|
|
return Object.keys(this.results).length !== 0
|
|
},
|
|
|
|
/**
|
|
* Return ordered results
|
|
*
|
|
* @return {Array}
|
|
*/
|
|
orderedResults() {
|
|
return this.typesIDs
|
|
.filter(type => type in this.results)
|
|
.map(type => ({
|
|
type,
|
|
list: this.results[type],
|
|
}))
|
|
},
|
|
|
|
/**
|
|
* Available filters
|
|
* We only show filters that are available on the results
|
|
*
|
|
* @return {string[]}
|
|
*/
|
|
availableFilters() {
|
|
return Object.keys(this.results)
|
|
},
|
|
|
|
/**
|
|
* Applied filters
|
|
*
|
|
* @return {string[]}
|
|
*/
|
|
usedFiltersIn() {
|
|
let match
|
|
const filters = []
|
|
while ((match = regexFilterIn.exec(this.query)) !== null) {
|
|
filters.push(match[2])
|
|
}
|
|
return filters
|
|
},
|
|
|
|
/**
|
|
* Applied anti filters
|
|
*
|
|
* @return {string[]}
|
|
*/
|
|
usedFiltersNot() {
|
|
let match
|
|
const filters = []
|
|
while ((match = regexFilterNot.exec(this.query)) !== null) {
|
|
filters.push(match[2])
|
|
}
|
|
return filters
|
|
},
|
|
|
|
/**
|
|
* Valid query empty content title
|
|
*
|
|
* @return {string}
|
|
*/
|
|
validQueryTitle() {
|
|
return this.triggered
|
|
? t('core', 'No results for {query}', { query: this.query })
|
|
: t('core', 'Press Enter to start searching')
|
|
},
|
|
|
|
/**
|
|
* Short query empty content description
|
|
*
|
|
* @return {string}
|
|
*/
|
|
shortQueryDescription() {
|
|
if (!this.isShortQuery) {
|
|
return ''
|
|
}
|
|
|
|
return n('core',
|
|
'Please enter {minSearchLength} character or more to search',
|
|
'Please enter {minSearchLength} characters or more to search',
|
|
this.minSearchLength,
|
|
{ minSearchLength: this.minSearchLength })
|
|
},
|
|
|
|
/**
|
|
* Is the current search too short
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isShortQuery() {
|
|
return this.query && this.query.trim().length < minSearchLength
|
|
},
|
|
|
|
/**
|
|
* Is the current search valid
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isValidQuery() {
|
|
return this.query && this.query.trim() !== '' && !this.isShortQuery
|
|
},
|
|
|
|
/**
|
|
* Have we reached the end of all types searches
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isDoneSearching() {
|
|
return Object.values(this.reached).every(state => state === false)
|
|
},
|
|
|
|
/**
|
|
* Is there any search in progress
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isLoading() {
|
|
return Object.values(this.loading).some(state => state === true)
|
|
},
|
|
},
|
|
|
|
async created() {
|
|
this.types = await getTypes()
|
|
this.logger.debug('Unified Search initialized with the following providers', this.types)
|
|
},
|
|
|
|
beforeDestroy() {
|
|
unsubscribe('files:navigation:changed', this.onNavigationChange)
|
|
},
|
|
|
|
mounted() {
|
|
// subscribe in mounted, as onNavigationChange relys on $el
|
|
subscribe('files:navigation:changed', this.onNavigationChange)
|
|
|
|
if (OCP.Accessibility.disableKeyboardShortcuts()) {
|
|
return
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
// if not already opened, allows us to trigger default browser on second keydown
|
|
if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
|
|
event.preventDefault()
|
|
this.open = true
|
|
} else if (event.ctrlKey && event.key === 'f' && this.open) {
|
|
// User wants to use the native browser search, so we close ours again
|
|
this.open = false
|
|
}
|
|
|
|
// https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
|
|
if (this.open) {
|
|
// If arrow down, focus next result
|
|
if (event.key === 'ArrowDown') {
|
|
this.focusNext(event)
|
|
}
|
|
|
|
// If arrow up, focus prev result
|
|
if (event.key === 'ArrowUp') {
|
|
this.focusPrev(event)
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
methods: {
|
|
async onOpen() {
|
|
// Update types list in the background
|
|
this.types = await getTypes()
|
|
},
|
|
onClose() {
|
|
emit('nextcloud:unified-search.close')
|
|
},
|
|
|
|
onNavigationChange() {
|
|
this.$el?.querySelector?.('form[role="search"]')?.reset?.()
|
|
},
|
|
|
|
/**
|
|
* Reset the search state
|
|
*/
|
|
onReset() {
|
|
emit('nextcloud:unified-search.reset')
|
|
this.logger.debug('Search reset')
|
|
this.query = ''
|
|
this.resetState()
|
|
this.focusInput()
|
|
},
|
|
async resetState() {
|
|
this.cursors = {}
|
|
this.limits = {}
|
|
this.reached = {}
|
|
this.results = {}
|
|
this.focused = null
|
|
this.triggered = false
|
|
await this.cancelPendingRequests()
|
|
},
|
|
|
|
/**
|
|
* Cancel any ongoing searches
|
|
*/
|
|
async cancelPendingRequests() {
|
|
// Cloning so we can keep processing other requests
|
|
const requests = this.requests.slice(0)
|
|
this.requests = []
|
|
|
|
// Cancel all pending requests
|
|
await Promise.all(requests.map(cancel => cancel()))
|
|
},
|
|
|
|
/**
|
|
* Focus the search input on next tick
|
|
*/
|
|
focusInput() {
|
|
this.$nextTick(() => {
|
|
this.$refs.input.focus()
|
|
this.$refs.input.select()
|
|
})
|
|
},
|
|
|
|
/**
|
|
* If we have results already, open first one
|
|
* If not, trigger the search again
|
|
*/
|
|
onInputEnter() {
|
|
if (this.hasResults) {
|
|
const results = this.getResultsList()
|
|
results[0].click()
|
|
return
|
|
}
|
|
this.onInput()
|
|
},
|
|
|
|
/**
|
|
* Start searching on input
|
|
*/
|
|
async onInput() {
|
|
// emit the search query
|
|
emit('nextcloud:unified-search.search', { query: this.query })
|
|
|
|
// Do not search if not long enough
|
|
if (this.query.trim() === '' || this.isShortQuery) {
|
|
for (const type of this.typesIDs) {
|
|
this.$delete(this.results, type)
|
|
}
|
|
return
|
|
}
|
|
|
|
let types = this.typesIDs
|
|
let query = this.query
|
|
|
|
// Filter out types
|
|
if (this.usedFiltersNot.length > 0) {
|
|
types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
|
|
}
|
|
|
|
// Only use those filters if any and check if they are valid
|
|
if (this.usedFiltersIn.length > 0) {
|
|
types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
|
|
}
|
|
|
|
// Remove any filters from the query
|
|
query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
|
|
|
|
// Reset search if the query changed
|
|
await this.resetState()
|
|
this.triggered = true
|
|
|
|
if (!types.length) {
|
|
// no results since no types were selected
|
|
this.logger.error('No types to search in')
|
|
return
|
|
}
|
|
|
|
this.$set(this.loading, 'all', true)
|
|
this.logger.debug(`Searching ${query} in`, types)
|
|
|
|
Promise.all(types.map(async type => {
|
|
try {
|
|
// Init cancellable request
|
|
const { request, cancel } = search({ type, query })
|
|
this.requests.push(cancel)
|
|
|
|
// Fetch results
|
|
const { data } = await request()
|
|
|
|
// Process results
|
|
if (data.ocs.data.entries.length > 0) {
|
|
this.$set(this.results, type, data.ocs.data.entries)
|
|
} else {
|
|
this.$delete(this.results, type)
|
|
}
|
|
|
|
// Save cursor if any
|
|
if (data.ocs.data.cursor) {
|
|
this.$set(this.cursors, type, data.ocs.data.cursor)
|
|
} else if (!data.ocs.data.isPaginated) {
|
|
// If no cursor and no pagination, we save the default amount
|
|
// provided by server's initial state `defaultLimit`
|
|
this.$set(this.limits, type, this.defaultLimit)
|
|
}
|
|
|
|
// Check if we reached end of pagination
|
|
if (data.ocs.data.entries.length < this.defaultLimit) {
|
|
this.$set(this.reached, type, true)
|
|
}
|
|
|
|
// If none already focused, focus the first rendered result
|
|
if (this.focused === null) {
|
|
this.focused = 0
|
|
}
|
|
return REQUEST_OK
|
|
} catch (error) {
|
|
this.$delete(this.results, type)
|
|
|
|
// If this is not a cancelled throw
|
|
if (error.response && error.response.status) {
|
|
this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
|
|
showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
|
|
return REQUEST_FAILED
|
|
}
|
|
return REQUEST_CANCELED
|
|
}
|
|
})).then(results => {
|
|
// Do not declare loading finished if the request have been cancelled
|
|
// This means another search was triggered and we're therefore still loading
|
|
if (results.some(result => result === REQUEST_CANCELED)) {
|
|
return
|
|
}
|
|
// We finished all searches
|
|
this.loading = {}
|
|
})
|
|
},
|
|
onInputDebounced: enableLiveSearch
|
|
? debounce(function(e) {
|
|
this.onInput(e)
|
|
}, 500)
|
|
: function() {
|
|
this.triggered = false
|
|
},
|
|
|
|
/**
|
|
* Load more results for the provided type
|
|
*
|
|
* @param {string} type type
|
|
*/
|
|
async loadMore(type) {
|
|
// If already loading, ignore
|
|
if (this.loading[type]) {
|
|
return
|
|
}
|
|
|
|
if (this.cursors[type]) {
|
|
// Init cancellable request
|
|
const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
|
|
this.requests.push(cancel)
|
|
|
|
// Fetch results
|
|
const { data } = await request()
|
|
|
|
// Save cursor if any
|
|
if (data.ocs.data.cursor) {
|
|
this.$set(this.cursors, type, data.ocs.data.cursor)
|
|
}
|
|
|
|
// Process results
|
|
if (data.ocs.data.entries.length > 0) {
|
|
this.results[type].push(...data.ocs.data.entries)
|
|
}
|
|
|
|
// Check if we reached end of pagination
|
|
if (data.ocs.data.entries.length < this.defaultLimit) {
|
|
this.$set(this.reached, type, true)
|
|
}
|
|
} else {
|
|
// If no cursor, we might have all the results already,
|
|
// let's fake pagination and show the next xxx entries
|
|
if (this.limits[type] && this.limits[type] >= 0) {
|
|
this.limits[type] += this.defaultLimit
|
|
|
|
// Check if we reached end of pagination
|
|
if (this.limits[type] >= this.results[type].length) {
|
|
this.$set(this.reached, type, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Focus result after render
|
|
if (this.focused !== null) {
|
|
this.$nextTick(() => {
|
|
this.focusIndex(this.focused)
|
|
})
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Return a subset of the array if the search provider
|
|
* doesn't supports pagination
|
|
*
|
|
* @param {Array} list the results
|
|
* @param {string} type the type
|
|
* @return {Array}
|
|
*/
|
|
limitIfAny(list, type) {
|
|
if (type in this.limits) {
|
|
return list.slice(0, this.limits[type])
|
|
}
|
|
return list
|
|
},
|
|
|
|
getResultsList() {
|
|
return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
|
|
},
|
|
|
|
/**
|
|
* Focus the first result if any
|
|
*
|
|
* @param {Event} event the keydown event
|
|
*/
|
|
focusFirst(event) {
|
|
const results = this.getResultsList()
|
|
if (results && results.length > 0) {
|
|
if (event) {
|
|
event.preventDefault()
|
|
}
|
|
this.focused = 0
|
|
this.focusIndex(this.focused)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Focus the next result if any
|
|
*
|
|
* @param {Event} event the keydown event
|
|
*/
|
|
focusNext(event) {
|
|
if (this.focused === null) {
|
|
this.focusFirst(event)
|
|
return
|
|
}
|
|
|
|
const results = this.getResultsList()
|
|
// If we're not focusing the last, focus the next one
|
|
if (results && results.length > 0 && this.focused + 1 < results.length) {
|
|
event.preventDefault()
|
|
this.focused++
|
|
this.focusIndex(this.focused)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Focus the previous result if any
|
|
*
|
|
* @param {Event} event the keydown event
|
|
*/
|
|
focusPrev(event) {
|
|
if (this.focused === null) {
|
|
this.focusFirst(event)
|
|
return
|
|
}
|
|
|
|
const results = this.getResultsList()
|
|
// If we're not focusing the first, focus the previous one
|
|
if (results && results.length > 0 && this.focused > 0) {
|
|
event.preventDefault()
|
|
this.focused--
|
|
this.focusIndex(this.focused)
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
* Focus the specified result index if it exists
|
|
*
|
|
* @param {number} index the result index
|
|
*/
|
|
focusIndex(index) {
|
|
const results = this.getResultsList()
|
|
if (results && results[index]) {
|
|
results[index].focus()
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set the current focused element based on the target
|
|
*
|
|
* @param {Event} event the focus event
|
|
*/
|
|
setFocusedIndex(event) {
|
|
const entry = event.target
|
|
const results = this.getResultsList()
|
|
const index = [...results].findIndex(search => search === entry)
|
|
if (index > -1) {
|
|
// let's not use focusIndex as the entry is already focused
|
|
this.focused = index
|
|
}
|
|
},
|
|
|
|
onClickFilter(filter) {
|
|
this.query = `${this.query} ${filter}`
|
|
.replace(/ {2}/g, ' ')
|
|
.trim()
|
|
this.onInput()
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
@use "sass:math";
|
|
|
|
$margin: 10px;
|
|
$input-height: 34px;
|
|
$input-padding: 10px;
|
|
|
|
.unified-search {
|
|
&__trigger-icon {
|
|
color: var(--color-background-plain-text) !important;
|
|
}
|
|
|
|
&__input-wrapper {
|
|
position: sticky;
|
|
// above search results
|
|
z-index: 2;
|
|
top: 0;
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
width: 100%;
|
|
background-color: var(--color-main-background);
|
|
|
|
label[for="unified-search__input"] {
|
|
align-self: flex-start;
|
|
font-weight: bold;
|
|
font-size: 19px;
|
|
margin-inline-start: 13px;
|
|
}
|
|
}
|
|
|
|
&__input-row {
|
|
display: flex;
|
|
width: 100%;
|
|
align-items: center;
|
|
}
|
|
|
|
&__filters {
|
|
margin-block: $margin;
|
|
margin-inline: math.div($margin, 2) 0;
|
|
padding-top: 5px;
|
|
ul {
|
|
display: inline-flex;
|
|
justify-content: space-between;
|
|
}
|
|
}
|
|
|
|
&__form {
|
|
position: relative;
|
|
width: 100%;
|
|
margin: $margin 0;
|
|
|
|
// Loading spinner
|
|
&::after {
|
|
inset-inline-start: auto $input-padding;
|
|
}
|
|
|
|
&-input,
|
|
&-reset {
|
|
margin: math.div($input-padding, 2);
|
|
}
|
|
|
|
&-input {
|
|
width: 100%;
|
|
height: $input-height;
|
|
padding: $input-padding;
|
|
|
|
&:focus,
|
|
&:focus-visible,
|
|
&:active {
|
|
border-color: 2px solid var(--color-main-text) !important;
|
|
box-shadow: 0 0 0 2px var(--color-main-background) !important;
|
|
}
|
|
|
|
&,
|
|
&[placeholder],
|
|
&::placeholder {
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
// Hide webkit clear search
|
|
&::-webkit-search-decoration,
|
|
&::-webkit-search-cancel-button,
|
|
&::-webkit-search-results-button,
|
|
&::-webkit-search-results-decoration {
|
|
-webkit-appearance: none;
|
|
}
|
|
}
|
|
|
|
&-reset,
|
|
&-submit {
|
|
position: absolute;
|
|
top: 0;
|
|
inset-inline-end: 4px;
|
|
width: $input-height - $input-padding;
|
|
height: $input-height - $input-padding;
|
|
min-height: 30px;
|
|
padding: 0;
|
|
opacity: .5;
|
|
border: none;
|
|
background-color: transparent;
|
|
margin-inline-end: 0;
|
|
|
|
&:hover,
|
|
&:focus,
|
|
&:active {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
&-submit {
|
|
inset-inline-end: 28px;
|
|
}
|
|
}
|
|
|
|
&__results {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
|
|
&-header {
|
|
display: block;
|
|
margin: $margin;
|
|
margin-bottom: $margin - 4px;
|
|
margin-inline-start: 13px;
|
|
color: var(--color-primary-element);
|
|
font-size: 19px;
|
|
font-weight: bold;
|
|
}
|
|
}
|
|
|
|
:deep(.unified-search__result-more) {
|
|
color: var(--color-text-maxcontrast);
|
|
}
|
|
|
|
.empty-content {
|
|
margin: 10vh 0;
|
|
|
|
:deep(.empty-content__title) {
|
|
font-weight: normal;
|
|
font-size: var(--default-font-size);
|
|
text-align: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
</style>
|
|
|