Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/appstore/lib/Search/AppSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ public function search(IUser $user, ISearchQuery $query): SearchResult {
$entry['name'],
'',
$entry['href'],
'icon-confirm'
$entry['icon'],
true,
);
}

Expand Down
11 changes: 6 additions & 5 deletions apps/settings/lib/Search/SectionSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,18 @@ public function searchSections(ISearchQuery $query, array $sections, string $sub
continue;
}

/**
* We can't use the icon URL at the moment as they don't invert correctly for dark theme
* $iconUrl = $section->getIcon();
*/
// The section's own icon, or a generic cog fallback.
$icon = $section->getIcon();
if ($icon === '') {
$icon = $this->urlGenerator->imagePath('settings', 'settings.svg');
}

$result[] = new SearchResultEntry(
'',
$section->getName(),
$subline,
$this->urlGenerator->linkToRouteAbsolute($routeName, ['section' => $section->getID()]),
'icon-settings-dark'
$icon,
);
}
}
Expand Down
74 changes: 74 additions & 0 deletions core/src/components/AppIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<span
class="app-icon"
:class="{ 'app-icon--outlined': outlined }">
<img
class="app-icon__img"
:src="icon"
alt=""
aria-hidden="true">
<!-- @slot Overlay positioned over the circle, e.g. an unread badge. -->
<slot />
</span>
</template>

<script setup lang="ts">
withDefaults(defineProps<{
/** URL of the app icon (painted bright on the coloured circle). */
icon: string
/** Render the circle as an outline only (no fill or gradient). */
outlined?: boolean
}>(), {
outlined: false,
})
</script>

<style scoped lang="scss">
.app-icon {
--app-icon-circle-size: calc(var(--default-grid-baseline) * 10);
--app-icon-icon-size: 22px;
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: var(--app-icon-circle-size);
height: var(--app-icon-circle-size);
border-radius: 50%;
background-color: var(--color-primary-element);
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0) 45%,
rgba(0, 0, 0, 0.15) 100%
);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 0 rgba(0, 0, 0, 0.2),
0 2px 4px rgba(0, 0, 0, 0.15);

&__img {
width: var(--app-icon-icon-size);
height: var(--app-icon-icon-size);
// App icons are bright; flip to dark when the circle background is bright (e.g. white in dark mode).
filter: var(--primary-invert-if-bright);
mask: var(--header-menu-icon-mask);
}

&--outlined {
background: transparent;
background-image: none;
box-shadow: inset 0 0 0 2px var(--color-border-maxcontrast);
}

&--outlined &__img {
filter: var(--background-invert-if-dark);
mask: none;
}
}
</style>
56 changes: 3 additions & 53 deletions core/src/components/AppItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
class="app-item"
:class="{
'app-item--active': app.active,
'app-item--outlined': outlined,
}"
:href="app.href"
:target="newTab ? '_blank' : undefined"
Expand All @@ -17,17 +16,12 @@
:tabindex="tabindex"
:title="app.name"
role="menuitem">
<span class="app-item__circle">
<img
class="app-item__icon"
:src="app.icon"
alt=""
aria-hidden="true">
<AppIcon :icon="app.icon" :outlined="outlined">
<span
v-if="app.unread"
class="app-item__unread"
aria-hidden="true" />
</span>
</AppIcon>
<span class="app-item__label">
{{ app.name }}
<span v-if="app.unread" class="hidden-visually">, {{ unreadLabel }}</span>
Expand All @@ -40,6 +34,7 @@ import type { INavigationEntry } from '../types/navigation.d.ts'

import { n } from '@nextcloud/l10n'
import { computed } from 'vue'
import AppIcon from './AppIcon.vue'

const props = withDefaults(defineProps<{
app: INavigationEntry
Expand Down Expand Up @@ -73,8 +68,6 @@ const unreadLabel = computed(() => {

<style scoped lang="scss">
.app-item {
--app-item-circle-size: calc(var(--default-grid-baseline) * 10);
--app-item-icon-size: 22px;
display: flex;
flex-direction: column;
align-items: center;
Expand All @@ -100,37 +93,6 @@ const unreadLabel = computed(() => {
box-shadow: inset 0 0 0 2px var(--color-primary-element);
}

&__circle {
box-sizing: border-box;
position: relative;
width: var(--app-item-circle-size);
height: var(--app-item-circle-size);
border-radius: 50%;
background-color: var(--color-primary-element);
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0) 45%,
rgba(0, 0, 0, 0.15) 100%
);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 0 rgba(0, 0, 0, 0.2),
0 2px 4px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
}

&__icon {
width: var(--app-item-icon-size);
height: var(--app-item-icon-size);
// App icons are bright by default; flip them to dark when the
// primary color (circle background) is bright (e.g. white in dark mode).
filter: var(--primary-invert-if-bright);
mask: var(--header-menu-icon-mask);
}

&__unread {
position: absolute;
top: 0;
Expand Down Expand Up @@ -160,17 +122,5 @@ const unreadLabel = computed(() => {
&--active &__label {
font-weight: bold;
}

// Outlined variant: no fill or gradient.
&--outlined &__circle {
background: transparent;
background-image: none;
box-shadow: inset 0 0 0 2px var(--color-border-maxcontrast);
}

&--outlined &__icon {
filter: var(--background-invert-if-dark);
mask: none;
}
}
</style>
111 changes: 89 additions & 22 deletions core/src/components/UnifiedSearch/SearchResult.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,29 @@
:href="resourceUrl"
target="_self">
<template #icon>
<AppIcon
v-if="isAppIcon"
class="result-item__app-icon"
:icon="icon" />
<div
v-else
aria-hidden="true"
class="result-item__icon"
:class="{
'result-item__icon--rounded': rounded,
'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
[icon]: !isValidIconOrPreviewUrl(icon),
}"
:style="{
backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
'result-item__icon--with-thumbnail': hasThumbnail,
[icon]: !iconIsUrl && !hasThumbnail,
}">
<img
v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
v-if="hasThumbnail"
:src="thumbnailUrl"
@error="thumbnailErrorHandler">
<img
v-else-if="iconIsUrl"
class="result-item__icon-img"
:src="icon"
alt=""
aria-hidden="true">
</div>
</template>
<template #subname>
Expand All @@ -38,10 +45,12 @@

<script>
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AppIcon from '../AppIcon.vue'

export default {
name: 'SearchResult',
components: {
AppIcon,
NcListItem,
},

Expand Down Expand Up @@ -108,6 +117,26 @@ export default {
}
},

computed: {
/** A usable thumbnail image (a preview/avatar), not errored. */
hasThumbnail() {
return this.isValidIconOrPreviewUrl(this.thumbnailUrl) && !this.thumbnailHasError
},

/** The icon is a real URL we can put in an <img>, not a legacy CSS class string. */
iconIsUrl() {
return this.isValidIconOrPreviewUrl(this.icon)
},

/**
* App-style icon (bright glyph on a primary circle, like the app menu). Providers
* flag it by marking the entry rounded with an icon URL and no thumbnail.
*/
isAppIcon() {
return this.rounded && this.iconIsUrl && !this.hasThumbnail
},
},

watch: {
thumbnailUrl() {
this.thumbnailHasError = false
Expand All @@ -128,58 +157,96 @@ export default {

<style lang="scss" scoped>
.result-item {
padding-inline: 0;

:deep(a) {
border: 2px solid transparent;
border-radius: var(--border-radius-large) !important;

// Hover/press: neutral gray fill only, no border.
&:active,
&:hover,
&:focus {
&:hover {
background-color: var(--color-background-hover);
border: 2px solid var(--color-border-maxcontrast);
}

// Plain Tab into a result keeps a visible focus ring (a11y). Normally the combobox
// keeps focus in the input and drives selection via `active` below.
&:focus-visible {
background-color: var(--color-background-hover);
border-color: var(--color-border-maxcontrast);
}

* {
cursor: pointer;
}
}

// NcListItem's `active` state paints a primary fill, white text and a blue stripe.
// We want a neutral look: the gray hover fill plus a maxcontrast border, readable text.
&.list-item__wrapper--active {
:deep(.list-item) {
background-color: var(--color-background-hover);

&:hover {
background-color: var(--color-background-hover);
}
}

// Undo the forced active text colour. Chain through the anchor to outrank
// NcListItem's own !important rule.
:deep(.list-item__anchor .list-item-content__name),
:deep(.list-item__anchor .list-item-content__subname),
:deep(.list-item__anchor .list-item-content__details),
:deep(.list-item__anchor .list-item-details__details) {
color: var(--color-main-text) !important;
}
}

&__icon {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
border-radius: var(--border-radius);
background-repeat: no-repeat;
background-position: center center;
background-size: 32px;
margin-inline-start: var(--default-grid-baseline);

&--rounded {
border-radius: calc(var(--default-clickable-area) / 2);
}

&--no-preview {
background-size: 32px;
}

&--with-thumbnail {
background-size: cover;
}

&--with-thumbnail:not(#{&}--rounded) {
border: 1px solid var(--color-border);
// compensate for border
max-height: calc(var(--default-clickable-area) - 2px);
max-width: calc(var(--default-clickable-area) - 2px);
}

img {
// A full-bleed thumbnail (preview or avatar) fills the box.
&--with-thumbnail img {
// Make sure to keep ratio
width: 100%;
height: 100%;

object-fit: cover;
object-position: center;
}

// A small monochrome glyph (e.g. a settings section), not a thumbnail.
&-img {
width: 20px;
height: 20px;
object-fit: contain;
// Dark monochrome icons invert to light in dark themes.
filter: var(--background-invert-if-dark);
}
}

// App results reuse the app-menu tile (AppIcon); size its circle to the icon column.
&__app-icon {
--app-icon-circle-size: var(--default-clickable-area);
margin-inline-start: var(--default-grid-baseline);
}
}
</style>
Loading
Loading