feat(core): Add centered search input to top bar

A centered, input-styled trigger replaces the top-right search icon as
the entry point to Unified Search. First phase of a phased rollout
that will later turn the trigger into a real inline-filtering input.

Refs: nextcloud/server#59888
Signed-off-by: nfebe <fenn25.fn@gmail.com>
This commit is contained in:
nfebe 2026-05-22 17:50:58 +01:00
parent 492a42b4b3
commit ba8b8c4c09
3 changed files with 195 additions and 21 deletions

View file

@ -11,9 +11,9 @@
:triggers="[]"
placement="bottom-start"
:skidding="popoverSkidding"
:setReturnFocus="returnFocusTarget"
popoverBaseClass="app-menu__popover-base"
popupRole="menu"
:set-return-focus="returnFocusTarget"
popover-base-class="app-menu__popover-base"
popup-role="menu"
@update:shown="opened = $event">
<template #trigger>
<NcButton
@ -40,7 +40,7 @@
ref="items"
:app="item"
:outlined="item.id === 'more-apps' || item.id === 'app-store'"
:newTab="item.id === 'app-store'"
:new-tab="item.id === 'app-store'"
:tabindex="i === focusedIndex ? 0 : -1" />
</div>
</div>
@ -431,8 +431,7 @@ export default defineComponent({
min-width: 0;
}
// Hide on small screens (matches $breakpoint-small-mobile in @nextcloud/vue).
@media only screen and (max-width: 512px) {
@media only screen and (max-width: 1024px) {
display: none !important;
}
}

View file

@ -0,0 +1,185 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<search class="unified-search-input" :class="[{ 'unified-search-input--mobile': isSmallMobile }]">
<NcHeaderButton
v-if="isSmallMobile"
:aria-label="placeholderText"
aria-haspopup="dialog"
:aria-expanded="expanded ? 'true' : 'false'"
@click="$emit('click', $event)">
<template #icon>
<IconMagnify :size="20" />
</template>
</NcHeaderButton>
<button
v-else
type="button"
class="unified-search-input__button"
aria-haspopup="dialog"
:aria-expanded="expanded ? 'true' : 'false'"
@click="$emit('click', $event)">
<IconMagnify
class="unified-search-input__icon"
:size="20"
aria-hidden="true" />
<span class="unified-search-input__label">
{{ placeholderText }}
</span>
</button>
</search>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
import IconMagnify from 'vue-material-design-icons/Magnify.vue'
/**
* First phase of the unified-search input: a button styled to look like an
* input field that opens the unified-search modal on click. A later phase
* will replace the button with a real input that filters results inline.
*
* Implemented as a custom component because no `@nextcloud/vue` component
* fits the design role here: NcInputField is a real input whose styling
* assumes a light page background and clashes with the themed header,
* and NcTextField has the same issue. On narrow viewports the trigger
* collapses to a standard NcHeaderButton so it matches the visual
* language of the other header items.
*/
defineProps<{
/** Whether the popup the input controls is currently open. Bound to aria-expanded. */
expanded?: boolean
}>()
defineEmits<{
click: [mouseEvent: MouseEvent]
}>()
const isSmallMobile = useIsSmallMobile()
const placeholderText = t('core', 'Search apps, files, tags, messages …')
</script>
<style lang="scss" scoped>
.unified-search-input {
&:not(.unified-search-input--mobile) {
position: absolute;
top: 0;
bottom: 0;
inset-inline: 0;
margin-inline: auto;
display: flex;
align-items: center;
width: clamp(200px, 35vw, 600px);
max-width: calc(100% - 32px);
pointer-events: none;
}
&--mobile {
display: contents;
}
&__button {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: calc(var(--default-clickable-area) - 8px);
padding: 0 12px;
border: none;
border-radius: var(--border-radius-element, 8px);
background-color: rgba(0, 0, 0, 0.15);
-webkit-backdrop-filter: var(--filter-background-blur);
backdrop-filter: var(--filter-background-blur);
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.12);
color: var(--color-background-plain-text);
cursor: pointer;
text-align: center;
font: inherit;
transition: background-color var(--animation-quick) ease-in-out;
&:hover {
background-color: rgba(0, 0, 0, 0.22);
}
&:focus-visible {
background-color: rgba(0, 0, 0, 0.22);
outline: 2px solid var(--color-background-plain-text);
outline-offset: 2px;
}
&:active {
background-color: rgba(0, 0, 0, 0.28) !important;
color: var(--color-background-plain-text) !important;
outline: none;
}
}
&__icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
&__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.unified-search-input--mobile :deep(.header-menu) {
height: var(--default-clickable-area);
}
.unified-search-input--mobile :deep(.header-menu__trigger) {
--button-size: var(--default-clickable-area) !important;
height: var(--default-clickable-area) !important;
}
.unified-search-input--mobile :deep(.button-vue) {
--color-main-text: var(--color-background-plain-text);
color: var(--color-background-plain-text);
border-radius: var(--border-radius-element) !important;
&:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.1) !important;
}
&:active:not(:disabled) {
background-color: rgba(0, 0, 0, 0.15) !important;
}
&:focus-visible {
background-color: rgba(0, 0, 0, 0.1) !important;
outline: none !important;
box-shadow: inset 0 0 0 2px var(--color-background-plain-text) !important;
}
}
[data-theme-dark] .unified-search-input__button,
[data-theme-dark-highcontrast] .unified-search-input__button {
background-color: color-mix(in srgb, var(--color-primary-element) 16%, transparent);
&:hover {
background-color: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
}
&:focus-visible {
background-color: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
}
&:active {
background-color: color-mix(in srgb, var(--color-primary-element) 28%, transparent) !important;
color: var(--color-background-plain-text) !important;
outline: none;
}
}
</style>

View file

@ -4,15 +4,9 @@
-->
<template>
<div class="unified-search-menu">
<NcHeaderButton
v-show="!showLocalSearch"
id="unified-search"
:aria-label="t('core', 'Unified search')"
@click="toggleUnifiedSearch">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnify" />
</template>
</NcHeaderButton>
<UnifiedSearchInput
:expanded="showUnifiedSearch || showLocalSearch"
@click="toggleUnifiedSearch" />
<UnifiedSearchLocalSearchBar
v-if="supportsLocalSearch"
:open.sync="showLocalSearch"
@ -26,14 +20,12 @@
</template>
<script lang="ts">
import { mdiMagnify } from '@mdi/js'
import { emit, subscribe } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useBrowserLocation } from '@vueuse/core'
import debounce from 'debounce'
import { defineComponent } from 'vue'
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import UnifiedSearchInput from '../components/UnifiedSearch/UnifiedSearchInput.vue'
import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue'
import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue'
import logger from '../logger.js'
@ -42,10 +34,9 @@ export default defineComponent({
name: 'UnifiedSearch',
components: {
NcHeaderButton,
NcIconSvgWrapper,
UnifiedSearchModal,
UnifiedSearchLocalSearchBar,
UnifiedSearchInput,
},
setup() {
@ -54,7 +45,6 @@ export default defineComponent({
return {
currentLocation,
mdiMagnify,
t,
}
},