Skip to content

Commit 733a9ee

Browse files
committed
feat(core): header-anchored combobox shell for unified search
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 68dd6ac commit 733a9ee

3 files changed

Lines changed: 559 additions & 285 deletions

File tree

core/src/components/UnifiedSearch/UnifiedSearchInput.vue

Lines changed: 184 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
- SPDX-License-Identifier: AGPL-3.0-or-later
44
-->
55
<template>
6-
<search class="unified-search-input" :class="[{ 'unified-search-input--mobile': isSmallMobile }]">
6+
<search
7+
class="unified-search-input"
8+
:class="{ 'unified-search-input--mobile': isSmallMobile }">
79
<NcHeaderButton
810
v-if="isSmallMobile"
911
:aria-label="placeholderText"
@@ -14,122 +16,247 @@
1416
<IconMagnify :size="20" />
1517
</template>
1618
</NcHeaderButton>
17-
<button
19+
<div
1820
v-else
19-
type="button"
20-
class="unified-search-input__button"
21-
aria-haspopup="dialog"
22-
:aria-expanded="expanded ? 'true' : 'false'"
23-
@click="$emit('click', $event)">
24-
<IconMagnify
25-
class="unified-search-input__icon"
26-
:size="20"
27-
aria-hidden="true" />
28-
<span class="unified-search-input__label">
29-
{{ placeholderText }}
30-
</span>
31-
</button>
21+
class="unified-search-input__field"
22+
:class="{ 'unified-search-input__field--active': isActive }">
23+
<!-- Resting overlay: centred magnifier + placeholder, purely decorative.
24+
A full-width input cannot group an icon with its own placeholder, so we
25+
paint the resting look on top and let clicks fall through to the input. -->
26+
<div class="unified-search-input__resting" aria-hidden="true">
27+
<IconMagnify :size="20" />
28+
<span>{{ placeholderText }}</span>
29+
</div>
30+
<IconMagnify class="unified-search-input__icon" :size="20" aria-hidden="true" />
31+
<input
32+
ref="inputRef"
33+
type="text"
34+
role="combobox"
35+
class="unified-search-input__input"
36+
aria-autocomplete="list"
37+
:aria-expanded="expanded ? 'true' : 'false'"
38+
:aria-label="placeholderText"
39+
:placeholder="isActive ? placeholderText : ''"
40+
:value="query"
41+
@focus="onFocus"
42+
@blur="isFocused = false"
43+
@input="onInput">
44+
<NcButton
45+
v-if="query.length > 0"
46+
variant="tertiary-no-background"
47+
class="unified-search-input__clear"
48+
:aria-label="t('core', 'Clear search')"
49+
@click="clearQuery">
50+
<template #icon>
51+
<IconClose :size="20" />
52+
</template>
53+
</NcButton>
54+
</div>
3255
</search>
3356
</template>
3457

3558
<script setup lang="ts">
3659
import { t } from '@nextcloud/l10n'
3760
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
61+
import { computed, ref } from 'vue'
62+
import NcButton from '@nextcloud/vue/components/NcButton'
3863
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
64+
import IconClose from 'vue-material-design-icons/Close.vue'
3965
import IconMagnify from 'vue-material-design-icons/Magnify.vue'
4066
4167
/**
42-
* First phase of the unified-search input: a button styled to look like an
43-
* input field that opens the unified-search modal on click. A later phase
44-
* will replace the button with a real input that filters results inline.
68+
* The unified-search input that lives in the header.
4569
*
46-
* Implemented as a custom component because no `@nextcloud/vue` component
47-
* fits the design role here: NcInputField is a real input whose styling
48-
* assumes a light page background and clashes with the themed header,
49-
* and NcTextField has the same issue. On narrow viewports the trigger
50-
* collapses to a standard NcHeaderButton so it matches the visual
51-
* language of the other header items.
70+
* Implemented as a plain <input> rather than NcTextField/NcInputField: those
71+
* assume a light form background and a floating label, which clashes with the
72+
* themed header and the resting "button" look and would need heavy overrides of
73+
* their internals. A custom input also lets us own the combobox semantics the
74+
* results popover needs. On narrow viewports it collapses to an NcHeaderButton
75+
* to match the other header items.
5276
*/
5377
54-
defineProps<{
55-
/** Whether the popup the input controls is currently open. Bound to aria-expanded. */
78+
const props = defineProps<{
79+
/** Whether the popover the input controls is open. Bound to aria-expanded. */
5680
expanded?: boolean
81+
query: string
5782
}>()
5883
59-
defineEmits<{
84+
const emit = defineEmits<{
6085
click: [mouseEvent: MouseEvent]
86+
focus: [focusEvent: FocusEvent]
87+
'update:query': [query: string]
6188
}>()
6289
6390
const isSmallMobile = useIsSmallMobile()
6491
const placeholderText = t('core', 'Search apps, files, tags, messages …')
92+
93+
const inputRef = ref<HTMLInputElement>()
94+
const isFocused = ref(false)
95+
96+
/** Active = focused or holding a query; drives the input-vs-button styling. */
97+
const isActive = computed(() => isFocused.value || props.query.length > 0)
98+
99+
/**
100+
* Track focus for the active styling and bubble the event so the parent can
101+
* react to it.
102+
*
103+
* @param event The focus event
104+
*/
105+
function onFocus(event: FocusEvent) {
106+
isFocused.value = true
107+
emit('focus', event)
108+
}
109+
110+
/**
111+
* Relay the typed value upward.
112+
*
113+
* @param event The input event
114+
*/
115+
function onInput(event: Event) {
116+
emit('update:query', (event.target as HTMLInputElement).value)
117+
}
118+
119+
/** Clear the query and keep focus in the input. */
120+
function clearQuery() {
121+
emit('update:query', '')
122+
inputRef.value?.focus()
123+
}
124+
125+
defineExpose({
126+
getInputElement: (): HTMLInputElement | null => inputRef.value ?? null,
127+
})
65128
</script>
66129

67130
<style lang="scss" scoped>
68131
.unified-search-input {
132+
// Positioned so z-index applies and the input paints above the search scrim
133+
position: relative;
134+
z-index: 51;
135+
69136
&:not(.unified-search-input--mobile) {
70137
display: flex;
71138
align-items: center;
72139
width: clamp(200px, 35vw, 600px);
73140
max-width: calc(100% - 32px);
74-
pointer-events: none;
75141
}
76142
77143
&--mobile {
78144
display: contents;
79145
}
80146
81-
&__button {
82-
pointer-events: auto;
147+
&__field {
148+
--resting-background: rgba(0, 0, 0, 0.15);
149+
--resting-background-hover: rgba(0, 0, 0, 0.22);
150+
position: relative;
83151
display: flex;
84152
align-items: center;
85-
justify-content: center;
86-
gap: 8px;
153+
// Match the default clickable area so the inner <input> (which the global
154+
// input reset forces to that height) fills the field without an override.
155+
height: var(--default-clickable-area);
87156
width: 100%;
88-
height: calc(var(--default-clickable-area) - 8px);
89-
padding: 0 12px;
90-
border: none;
91157
border-radius: var(--border-radius-element, 8px);
92-
background-color: rgba(0, 0, 0, 0.15);
158+
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.12);
159+
// Resting: subdued "button" look that sits on the themed header
160+
background-color: var(--resting-background);
93161
-webkit-backdrop-filter: var(--filter-background-blur);
94162
backdrop-filter: var(--filter-background-blur);
95-
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.12);
96-
color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain));
97-
cursor: pointer;
98-
text-align: center;
99-
font: inherit;
100163
transition: background-color var(--animation-quick) ease-in-out;
101164
102-
&:hover {
103-
background-color: rgba(0, 0, 0, 0.22);
165+
&:hover:not(.unified-search-input__field--active) {
166+
background-color: var(--resting-background-hover);
104167
}
105168
106-
&:focus-visible {
107-
background-color: rgba(0, 0, 0, 0.22);
108-
outline: 2px solid var(--color-background-plain-text);
109-
outline-offset: 2px;
169+
// Active: real input surface once focused or filled
170+
&--active {
171+
background-color: var(--color-main-background);
172+
box-shadow: none;
110173
}
174+
}
111175
112-
&:active {
113-
background-color: rgba(0, 0, 0, 0.28) !important;
114-
color: var(--color-background-plain-text) !important;
115-
outline: none;
176+
// Resting look: magnifier + placeholder centred as a group, painted over the
177+
// (empty, pointer-transparent) input. Fades out as the field becomes active.
178+
&__resting {
179+
position: absolute;
180+
inset: 0;
181+
display: flex;
182+
align-items: center;
183+
justify-content: center;
184+
gap: 6px;
185+
padding-inline: 12px;
186+
pointer-events: none;
187+
color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain));
188+
transition: opacity var(--animation-quick) ease-in-out;
189+
190+
span {
191+
overflow: hidden;
192+
white-space: nowrap;
193+
text-overflow: ellipsis;
194+
}
195+
196+
.unified-search-input__field--active & {
197+
opacity: 0;
116198
}
117199
}
118200
201+
// Left magnifier for the active state; hidden while the resting overlay shows
119202
&__icon {
120-
flex-shrink: 0;
203+
position: absolute;
204+
inset-inline-start: 12px;
121205
display: flex;
122-
align-items: center;
206+
color: var(--color-main-text);
207+
opacity: 0;
208+
pointer-events: none;
209+
transition: opacity var(--animation-quick) ease-in-out;
210+
211+
.unified-search-input__field--active & {
212+
opacity: 1;
213+
}
123214
}
124215
125-
&__label {
216+
// Only visible once active (at rest it's empty and covered by the overlay),
217+
// so it's styled for the active/white surface throughout.
218+
&__input {
219+
flex: 1;
126220
min-width: 0;
127-
overflow: hidden;
128-
text-overflow: ellipsis;
129-
white-space: nowrap;
221+
height: 100%;
222+
margin: 0;
223+
// Leading space for the active magnifier
224+
padding-inline: calc(var(--default-clickable-area) - var(--default-grid-baseline)) 12px;
225+
// Opt out of NC's global input chrome (core/css/inputs.scss adds a border,
226+
// radius and focus box-shadow to any text input not in its exclusion list).
227+
// !important because that global focus rule outweighs a scoped class.
228+
border: none !important;
229+
border-radius: 0 !important;
230+
box-shadow: none !important;
231+
background-color: transparent;
232+
color: var(--color-main-text);
233+
font-size: var(--default-font-size);
234+
235+
&::placeholder {
236+
opacity: 1;
237+
color: var(--color-text-maxcontrast);
238+
}
239+
240+
&:focus-visible {
241+
outline: none;
242+
}
243+
}
244+
245+
&__clear {
246+
flex-shrink: 0;
247+
margin-inline-end: 2px;
130248
}
131249
}
132250
251+
// On dark themes the plain overlay is nearly invisible on the header, so tint
252+
// the resting background with the primary colour instead.
253+
[data-theme-dark] .unified-search-input__field,
254+
[data-theme-dark-highcontrast] .unified-search-input__field {
255+
--resting-background: color-mix(in srgb, var(--color-primary-element) 16%, transparent);
256+
--resting-background-hover: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
257+
}
258+
259+
// Mobile: NcHeaderButton styling to match the other header items
133260
.unified-search-input--mobile :deep(.header-menu) {
134261
height: var(--default-clickable-area);
135262
}
@@ -158,23 +285,4 @@ const placeholderText = t('core', 'Search apps, files, tags, messages …')
158285
box-shadow: inset 0 0 0 2px var(--color-background-plain-text) !important;
159286
}
160287
}
161-
162-
[data-theme-dark] .unified-search-input__button,
163-
[data-theme-dark-highcontrast] .unified-search-input__button {
164-
background-color: color-mix(in srgb, var(--color-primary-element) 16%, transparent);
165-
166-
&:hover {
167-
background-color: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
168-
}
169-
170-
&:focus-visible {
171-
background-color: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
172-
}
173-
174-
&:active {
175-
background-color: color-mix(in srgb, var(--color-primary-element) 28%, transparent) !important;
176-
color: var(--color-background-plain-text) !important;
177-
outline: none;
178-
}
179-
}
180288
</style>

0 commit comments

Comments
 (0)