Skip to content

Commit 85a050a

Browse files
committed
feat(core): redesign unified search result presentation
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent e7a5147 commit 85a050a

19 files changed

Lines changed: 1287 additions & 179 deletions

apps/settings/lib/Search/SectionSearch.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,7 @@ public function searchSections(ISearchQuery $query, array $sections, string $sub
121121
continue;
122122
}
123123

124-
// The section's own icon, falling back to a generic cog when it has none.
125-
// These are dark monochrome glyphs; the client inverts them for dark
126-
// themes via --background-invert-if-dark.
124+
// The section's own icon, or a generic cog fallback.
127125
$icon = $section->getIcon();
128126
if ($icon === '') {
129127
$icon = $this->urlGenerator->imagePath('settings', 'settings.svg');

core/src/components/AppIcon.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
<script setup lang="ts">
2121
withDefaults(defineProps<{
22-
/** URL of the app icon. Painted bright on the coloured circle, like the app menu. */
22+
/** URL of the app icon (painted bright on the coloured circle). */
2323
icon: string
2424
/** Render the circle as an outline only (no fill or gradient). */
2525
outlined?: boolean
@@ -55,13 +55,11 @@ withDefaults(defineProps<{
5555
&__img {
5656
width: var(--app-icon-icon-size);
5757
height: var(--app-icon-icon-size);
58-
// App icons are bright by default; flip them to dark when the
59-
// primary color (circle background) is bright (e.g. white in dark mode).
58+
// App icons are bright; flip to dark when the circle background is bright (e.g. white in dark mode).
6059
filter: var(--primary-invert-if-bright);
6160
mask: var(--header-menu-icon-mask);
6261
}
6362
64-
// Outlined variant: no fill or gradient.
6563
&--outlined {
6664
background: transparent;
6765
background-image: none;

core/src/components/UnifiedSearch/SearchResult.vue

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,29 @@
1212
:href="resourceUrl"
1313
target="_self">
1414
<template #icon>
15+
<AppIcon
16+
v-if="isAppIcon"
17+
class="result-item__app-icon"
18+
:icon="icon" />
1519
<div
20+
v-else
1621
aria-hidden="true"
1722
class="result-item__icon"
1823
:class="{
1924
'result-item__icon--rounded': rounded,
20-
'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
21-
'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
22-
[icon]: !isValidIconOrPreviewUrl(icon),
23-
}"
24-
:style="{
25-
backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
25+
'result-item__icon--with-thumbnail': hasThumbnail,
26+
[icon]: !iconIsUrl && !hasThumbnail,
2627
}">
2728
<img
28-
v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
29+
v-if="hasThumbnail"
2930
:src="thumbnailUrl"
3031
@error="thumbnailErrorHandler">
32+
<img
33+
v-else-if="iconIsUrl"
34+
class="result-item__icon-img"
35+
:src="icon"
36+
alt=""
37+
aria-hidden="true">
3138
</div>
3239
</template>
3340
<template #subname>
@@ -38,10 +45,12 @@
3845

3946
<script>
4047
import NcListItem from '@nextcloud/vue/components/NcListItem'
48+
import AppIcon from '../AppIcon.vue'
4149
4250
export default {
4351
name: 'SearchResult',
4452
components: {
53+
AppIcon,
4554
NcListItem,
4655
},
4756
@@ -108,6 +117,26 @@ export default {
108117
}
109118
},
110119
120+
computed: {
121+
/** A usable thumbnail image (a preview/avatar), not errored. */
122+
hasThumbnail() {
123+
return this.isValidIconOrPreviewUrl(this.thumbnailUrl) && !this.thumbnailHasError
124+
},
125+
126+
/** The icon is a real URL we can put in an <img>, not a legacy CSS class string. */
127+
iconIsUrl() {
128+
return this.isValidIconOrPreviewUrl(this.icon)
129+
},
130+
131+
/**
132+
* App-style icon (bright glyph on a primary circle, like the app menu). Providers
133+
* flag it by marking the entry rounded with an icon URL and no thumbnail.
134+
*/
135+
isAppIcon() {
136+
return this.rounded && this.iconIsUrl && !this.hasThumbnail
137+
},
138+
},
139+
111140
watch: {
112141
thumbnailUrl() {
113142
this.thumbnailHasError = false
@@ -128,58 +157,96 @@ export default {
128157

129158
<style lang="scss" scoped>
130159
.result-item {
160+
padding-inline: 0;
161+
131162
:deep(a) {
132163
border: 2px solid transparent;
133164
border-radius: var(--border-radius-large) !important;
134165
166+
// Hover/press: neutral gray fill only, no border.
135167
&:active,
136-
&:hover,
137-
&:focus {
168+
&:hover {
138169
background-color: var(--color-background-hover);
139-
border: 2px solid var(--color-border-maxcontrast);
170+
}
171+
172+
// Plain Tab into a result keeps a visible focus ring (a11y). Normally the combobox
173+
// keeps focus in the input and drives selection via `active` below.
174+
&:focus-visible {
175+
background-color: var(--color-background-hover);
176+
border-color: var(--color-border-maxcontrast);
140177
}
141178
142179
* {
143180
cursor: pointer;
144181
}
145182
}
146183
184+
// NcListItem's `active` state paints a primary fill, white text and a blue stripe.
185+
// We want a neutral look: the gray hover fill plus a maxcontrast border, readable text.
186+
&.list-item__wrapper--active {
187+
:deep(.list-item) {
188+
background-color: var(--color-background-hover);
189+
190+
&:hover {
191+
background-color: var(--color-background-hover);
192+
}
193+
}
194+
195+
// Undo the forced active text colour. Chain through the anchor to outrank
196+
// NcListItem's own !important rule.
197+
:deep(.list-item__anchor .list-item-content__name),
198+
:deep(.list-item__anchor .list-item-content__subname),
199+
:deep(.list-item__anchor .list-item-content__details),
200+
:deep(.list-item__anchor .list-item-details__details) {
201+
color: var(--color-main-text) !important;
202+
}
203+
}
204+
147205
&__icon {
206+
display: flex;
207+
align-items: center;
208+
justify-content: center;
148209
overflow: hidden;
149210
width: var(--default-clickable-area);
150211
height: var(--default-clickable-area);
151212
border-radius: var(--border-radius);
152-
background-repeat: no-repeat;
153-
background-position: center center;
154-
background-size: 32px;
213+
margin-inline-start: var(--default-grid-baseline);
155214
156215
&--rounded {
157216
border-radius: calc(var(--default-clickable-area) / 2);
158217
}
159218
160-
&--no-preview {
161-
background-size: 32px;
162-
}
163-
164-
&--with-thumbnail {
165-
background-size: cover;
166-
}
167-
168219
&--with-thumbnail:not(#{&}--rounded) {
169220
border: 1px solid var(--color-border);
170221
// compensate for border
171222
max-height: calc(var(--default-clickable-area) - 2px);
172223
max-width: calc(var(--default-clickable-area) - 2px);
173224
}
174225
175-
img {
226+
// A full-bleed thumbnail (preview or avatar) fills the box.
227+
&--with-thumbnail img {
176228
// Make sure to keep ratio
177229
width: 100%;
178230
height: 100%;
179231
180232
object-fit: cover;
181233
object-position: center;
182234
}
235+
236+
// A small monochrome glyph (e.g. a settings section), not a thumbnail.
237+
&-img {
238+
width: 20px;
239+
height: 20px;
240+
object-fit: contain;
241+
// Dark monochrome icons invert to light in dark themes.
242+
filter: var(--background-invert-if-dark);
243+
}
244+
}
245+
246+
// App results reuse the app-menu tile (AppIcon); size its circle to the icon column.
247+
&__app-icon {
248+
--app-icon-circle-size: var(--default-clickable-area);
249+
margin-inline-start: var(--default-grid-baseline);
183250
}
184251
}
185252
</style>

core/src/components/UnifiedSearch/UnifiedSearchInput.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
<IconFilterVariant :size="20" />
5858
</template>
5959
</NcButton>
60+
<!-- Loading spinner while a search is in flight. -->
61+
<NcLoadingIcon
62+
v-if="loading"
63+
class="unified-search-input__loading"
64+
:size="20" />
6065
<!-- Trailing X: clears the query, or closes the search when the field is empty. -->
6166
<NcButton
6267
v-if="isActive"
@@ -87,6 +92,7 @@ import { computed, ref } from 'vue'
8792
import NcButton from '@nextcloud/vue/components/NcButton'
8893
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
8994
import NcKbd from '@nextcloud/vue/components/NcKbd'
95+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
9096
import IconClose from 'vue-material-design-icons/Close.vue'
9197
import IconFilterVariant from 'vue-material-design-icons/FilterVariant.vue'
9298
import IconMagnify from 'vue-material-design-icons/Magnify.vue'
@@ -106,6 +112,8 @@ const props = defineProps<{
106112
/** Id of the active result row, for aria-activedescendant. Empty when none. */
107113
activeDescendantId?: string
108114
query: string
115+
/** A search is in flight: show the loading spinner. */
116+
loading?: boolean
109117
/** Filters are already revealed, so hide the pre-typing funnel. */
110118
filtersRevealed?: boolean
111119
}>()
@@ -391,6 +399,13 @@ defineExpose({ focus })
391399
margin-inline-end: 2px;
392400
}
393401
402+
&__loading {
403+
flex-shrink: 0;
404+
display: flex;
405+
align-items: center;
406+
margin-inline: 4px;
407+
}
408+
394409
// Pinned to the trailing edge, overlaid on the input (pointer-events: none so a
395410
// click there still focuses the field).
396411
&__shortcut {
@@ -429,9 +444,11 @@ defineExpose({ focus })
429444
--resting-background-hover: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
430445
}
431446
432-
// translateX is physical, so flip the resting slide under RTL to keep it moving
433-
// toward the leading (right) edge.
434-
[dir=rtl] .unified-search-input__resting {
447+
// translateX is physical, so flip the resting slide under RTL to keep it moving toward
448+
// the leading (right) edge. :dir(rtl) tracks the computed direction, so it applies whether
449+
// RTL comes from the body dir attribute or a direction style (an [dir=rtl] attribute
450+
// selector would miss the latter).
451+
.unified-search-input__resting:dir(rtl) {
435452
--slide-sign: -1;
436453
}
437454

0 commit comments

Comments
 (0)