Skip to content

Commit c849c3b

Browse files
authored
Merge pull request #62963 from nextcloud/feat/app-menu-default-order
App menu visual polishing
2 parents f9bd1e1 + 21c5152 commit c849c3b

10 files changed

Lines changed: 144 additions & 56 deletions

File tree

‎apps/appstore/appinfo/info.xml‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@
2121
</dependencies>
2222

2323
<navigations>
24-
<navigation role="admin">
25-
<name>Appstore</name>
26-
<route>appstore.page.viewApps</route>
27-
<icon>app.svg</icon>
28-
<order>99</order>
29-
</navigation>
30-
24+
<!-- No app menu entry: the menu already has a "More apps" tile linking here -->
3125
<navigation role="admin">
3226
<name>Apps</name>
3327
<route>appstore.page.viewApps</route>

‎core/src/components/AppIcon.vue‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,16 @@ $bevel:
9393
}
9494
}
9595
96+
// Utility entries ("More apps", "App store") stay subdued: a plain circle
97+
// with the icon in the same muted color as the label.
9698
&--outlined {
9799
background: transparent;
98100
background-image: none;
99-
box-shadow: inset 0 0 0 2px var(--color-border-maxcontrast);
101+
box-shadow: inset 0 0 0 2px var(--color-border);
100102
}
101103
102104
&--outlined &__img {
103-
background-color: var(--color-main-text);
105+
background-color: var(--color-text-maxcontrast);
104106
background-image: none;
105107
}
106108
}

‎core/src/components/AppItem.vue‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
class="app-item"
99
:class="{
1010
'app-item--active': app.active,
11+
'app-item--outlined': outlined,
1112
}"
1213
:href="app.href"
1314
:target="newTab ? '_blank' : undefined"
@@ -125,5 +126,10 @@ const unreadLabel = computed(() => {
125126
&--active &__label {
126127
font-weight: bold;
127128
}
129+
130+
// Utility entries ("More apps", "App store") are subdued, they are not apps.
131+
&--outlined &__label {
132+
color: var(--color-text-maxcontrast);
133+
}
128134
}
129135
</style>

‎core/src/components/AppMenu.vue‎

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,17 @@
5454
:aria-expanded="opened ? 'true' : 'false'"
5555
@click="onTriggerClick('currentApp')">
5656
<template #icon>
57-
<!-- Settings sub-sections share one generic cog. An inline MDI icon
58-
inherits the button's currentColor (--color-background-plain-text),
59-
so it stays legible on both bright and dark headers without a filter. -->
57+
<!-- Settings sections and entries without an icon show a generic cog. -->
6058
<IconCog
61-
v-if="currentApp.type === 'settings'"
59+
v-if="isSettingsSection || !currentAppIcon"
6260
class="app-menu__current-app-cog"
6361
:size="20" />
64-
<img
65-
v-else
66-
class="app-menu__current-app-icon"
67-
:src="currentApp.icon"
68-
alt=""
69-
aria-hidden="true">
62+
<!-- Outer element carries the header fade, inner one the icon shape. -->
63+
<span v-else class="app-menu__current-app-icon">
64+
<span
65+
class="app-menu__current-app-glyph"
66+
:style="currentAppIconStyle" />
67+
</span>
7068
</template>
7169
<span class="app-menu__current-app-name">
7270
{{ displayName }}
@@ -94,6 +92,15 @@ import logger from '../logger.js'
9492
// Settings IDs that represent actions, not navigable pages.
9593
const SETTINGS_ACTION_IDS = new Set(['logout'])
9694
95+
const SETTINGS_SECTION_IDS = new Set(['settings_personal', 'settings_administration', 'accessibility_settings'])
96+
97+
// The profile entry is named "View profile" for the account menu, which is an
98+
// action rather than a page name. The header shows where you are instead.
99+
const PROFILE_ID = 'profile'
100+
101+
// Entry of the app management page, the target of the "More apps" tile.
102+
const APP_MANAGEMENT_ID = 'appstore'
103+
97104
export default defineComponent({
98105
name: 'AppMenu',
99106
@@ -169,18 +176,36 @@ export default defineComponent({
169176
?? Object.values(this.settingsList).find((entry) => entry.active && !SETTINGS_ACTION_IDS.has(entry.id))
170177
},
171178
172-
// Trigger label. Settings sub-section names ("Personal info",
173-
// "Appearance and accessibility", ...) are too long and varied to
174-
// surface in the header; collapse them all to a single "Settings".
179+
isSettingsSection(): boolean {
180+
return this.currentApp !== undefined && SETTINGS_SECTION_IDS.has(this.currentApp.id)
181+
},
182+
175183
displayName(): string {
176184
if (!this.currentApp) {
177185
return ''
178186
}
179-
return this.currentApp.type === 'settings'
180-
? t('core', 'Settings')
187+
if (this.isSettingsSection) {
188+
return t('core', 'Settings')
189+
}
190+
return this.currentApp.id === PROFILE_ID
191+
? t('core', 'Profile')
181192
: this.currentApp.name
182193
},
183194
195+
// The profile entry ships no icon of its own, so use the generic one.
196+
currentAppIcon(): string {
197+
if (this.currentApp?.id === PROFILE_ID) {
198+
return imagePath('core', 'actions/user.svg')
199+
}
200+
return this.currentApp?.icon ?? ''
201+
},
202+
203+
// Masked like AppIcon.vue, so dark icons stay legible on the header.
204+
// Escaped so a crafted path cannot break out of the url() token.
205+
currentAppIconStyle(): Record<string, string> {
206+
return { '--app-icon-url': `url("${this.currentAppIcon.replace(/["\\]/g, '\\$&')}")` }
207+
},
208+
184209
// aria-label overrides the inner span text, so the displayed name
185210
// has to be duplicated here for screen readers.
186211
currentAppLabel(): string {
@@ -193,7 +218,9 @@ export default defineComponent({
193218
// utility tile is "More apps" (local app management) for admins and
194219
// "App store" (apps.nextcloud.com) for everyone else.
195220
gridItems(): INavigationEntry[] {
196-
const tail = this.isAdmin ? this.moreAppsEntry : this.appStoreEntry
221+
const tail = this.isAdmin
222+
? { ...this.moreAppsEntry, active: this.currentApp?.id === APP_MANAGEMENT_ID }
223+
: this.appStoreEntry
197224
return [...this.appList, tail]
198225
},
199226
},
@@ -459,13 +486,29 @@ export default defineComponent({
459486
}
460487
461488
&__current-app-icon {
489+
display: flex;
462490
width: calc(var(--default-grid-baseline) * 5);
463491
height: calc(var(--default-grid-baseline) * 5);
464-
// Theme-aware inversion + vertical alpha fade via --header-menu-icon-mask.
465-
filter: var(--background-image-invert-if-bright);
492+
// Vertical alpha fade, like the cog and the other header icons.
466493
mask: var(--header-menu-icon-mask);
467494
}
468495
496+
&__current-app-glyph {
497+
width: 100%;
498+
height: 100%;
499+
// Masked rather than shown: app icons ship a hardcoded fill, so the
500+
// color has to come from the background. Matches AppIcon.vue.
501+
background-color: var(--color-background-plain-text);
502+
mask: var(--app-icon-url) center / contain no-repeat;
503+
}
504+
505+
// Masked backgrounds are not force-adjusted the way <img> is.
506+
@media (forced-colors: active) {
507+
&__current-app-glyph {
508+
background-color: CanvasText;
509+
}
510+
}
511+
469512
&__current-app-cog {
470513
mask: var(--header-menu-icon-mask);
471514
}

‎core/src/tests/components/AppMenu.spec.ts‎

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ function fakeApps(): INavigationEntry[] {
6565
]
6666
}
6767

68+
// Mimics a page where the active entry is a settings one, so it is excluded
69+
// from the `apps` list. The object shape matches PHP's serialization, which
70+
// ships getAll('settings') keyed by entry id.
71+
function mockActiveSettingsEntry(overrides: Partial<INavigationEntry>): void {
72+
const entry = makeApp({ type: 'settings', active: true, ...overrides })
73+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
74+
if (key === 'apps') {
75+
return [makeApp({ id: 'files', name: 'Files', active: false })]
76+
}
77+
if (key === 'settingsNavEntries') {
78+
return { [entry.id]: entry }
79+
}
80+
return fallback
81+
})
82+
}
83+
6884
function eightApps(activeIndex: number = -1): INavigationEntry[] {
6985
const ids = ['files', 'mail', 'calendar', 'contacts', 'notes', 'photos', 'talk', 'deck']
7086
return ids.map((id, i) => makeApp({
@@ -137,6 +153,18 @@ describe('core: AppMenu', () => {
137153
expect(moreApps).toBeTruthy()
138154
})
139155

156+
it('marks the "More apps" tile active on the app management page', async () => {
157+
auth.getCurrentUser.mockReturnValue({ isAdmin: true })
158+
mockActiveSettingsEntry({ id: 'appstore', name: 'Apps', href: '/settings/apps' })
159+
const wrapper = mount(AppMenu, { attachTo: document.body })
160+
await openPopover(wrapper)
161+
162+
const moreApps = Array.from(document.querySelectorAll('[role="menuitem"]'))
163+
.find((el) => el.textContent?.includes('More apps'))
164+
expect(moreApps?.classList.contains('app-item--active')).toBe(true)
165+
expect(moreApps?.getAttribute('aria-current')).toBe('page')
166+
})
167+
140168
it('ArrowRight moves the roving stop from index 0 to index 1 and focuses it', async () => {
141169
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => key === 'apps' ? eightApps() : fallback)
142170
const wrapper = mount(AppMenu, { attachTo: document.body })
@@ -174,41 +202,54 @@ describe('core: AppMenu', () => {
174202
})
175203

176204
it('falls back to the active settings entry when no app is active', () => {
177-
// Mimics being on /settings/admin/* where the active entry is registered
178-
// as type=settings (NavigationManager) and excluded from the `apps` list.
179-
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
180-
if (key === 'apps') {
181-
return [makeApp({ id: 'files', name: 'Files', active: false })]
182-
}
183-
if (key === 'settingsNavEntries') {
184-
// Object keyed by entry id — matches PHP's serialization shape
185-
// (TemplateLayout ships the filtered associative array as-is).
186-
return {
187-
admin_settings: makeApp({
188-
id: 'admin_settings',
189-
name: 'Administration settings',
190-
type: 'settings',
191-
href: '/settings/admin/overview',
192-
icon: '/settings/img/admin.svg',
193-
active: true,
194-
}),
195-
}
196-
}
197-
return fallback
205+
// Mimics being on /settings/admin/*
206+
mockActiveSettingsEntry({
207+
id: 'settings_administration',
208+
name: 'Administration settings',
209+
href: '/settings/admin/overview',
210+
icon: '/settings/img/admin.svg',
198211
})
199212
const wrapper = mount(AppMenu, { attachTo: document.body })
200213
expect(wrapper.find('.app-menu__current-app').exists()).toBe(true)
201214
// Settings sub-section names are collapsed to a single "Settings" label.
202215
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Settings')
203216
})
204217

218+
it('keeps the own name of settings entries outside the settings app', () => {
219+
// On /settings/apps the active entry is the app management one, which
220+
// shows "Apps" here and in the account menu, not "Settings".
221+
mockActiveSettingsEntry({ id: 'appstore', name: 'Apps', href: '/settings/apps', icon: '/apps/appstore/img/app-dark.svg' })
222+
const wrapper = mount(AppMenu, { attachTo: document.body })
223+
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Apps')
224+
// Its own icon, not the generic cog of the settings sections
225+
expect(wrapper.find('.app-menu__current-app-glyph').attributes('style'))
226+
.toContain('/apps/appstore/img/app-dark.svg')
227+
})
228+
229+
it('shows the profile page as "Profile" with the generic user icon', () => {
230+
// The profile app names its entry "View profile" for the account menu
231+
// and ships no icon, so both are replaced here.
232+
mockActiveSettingsEntry({ id: 'profile', name: 'View profile', href: '/u/admin', icon: '' })
233+
const wrapper = mount(AppMenu, { attachTo: document.body })
234+
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Profile')
235+
expect(wrapper.find('.app-menu__current-app-glyph').attributes('style'))
236+
.toContain('/core/img/actions/user.svg')
237+
})
238+
239+
it('falls back to the cog for entries without an icon', () => {
240+
mockActiveSettingsEntry({ id: 'help', name: 'Help & privacy', href: '/settings/help', icon: '' })
241+
const wrapper = mount(AppMenu, { attachTo: document.body })
242+
expect(wrapper.find('.app-menu__current-app-cog').exists()).toBe(true)
243+
expect(wrapper.find('.app-menu__current-app-glyph').exists()).toBe(false)
244+
})
245+
205246
it('prefers the active app over a settings entry when both are marked active', () => {
206247
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
207248
if (key === 'apps') {
208249
return [makeApp({ id: 'files', name: 'Files', active: true })]
209250
}
210251
if (key === 'settingsNavEntries') {
211-
return { admin_settings: makeApp({ id: 'admin_settings', name: 'Administration settings', type: 'settings', active: true }) }
252+
return { settings_administration: makeApp({ id: 'settings_administration', name: 'Administration settings', type: 'settings', active: true }) }
212253
}
213254
return fallback
214255
})

‎dist/core-main.js‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/core-main.js.map‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/core-unified-search.js‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/core-unified-search.js.map‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/playwright/e2e/core/header-app-menu.spec.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ adminTest.describe('Header: App menu (waffle launcher) – admin', () => {
5454
await expectWaffleMenuContainsApps(navigationHeader, [
5555
{ name: 'Files', href: '/apps/files' },
5656
{ name: 'Dashboard', href: '/apps/dashboard' },
57-
{ name: 'Appstore', href: '/settings/apps' },
57+
// The app management page is only linked by the "More apps" tile,
58+
// the app itself does not add an entry to the menu
59+
{ name: 'More apps', href: '/settings/apps' },
5860
])
5961
})
6062
})

0 commit comments

Comments
 (0)