Skip to content

Commit 28d8451

Browse files
committed
feat(core): app menu polish for NC34
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 353f438 commit 28d8451

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

core/src/components/AppMenu.vue

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@
4949
v-if="currentApp"
5050
class="app-menu__current-app"
5151
variant="tertiary-no-background"
52-
:aria-label="t('core', 'Open apps menu')"
52+
:aria-label="currentAppLabel"
5353
aria-haspopup="menu"
5454
:aria-expanded="opened ? 'true' : 'false'"
5555
@click="onTriggerClick('currentApp')">
5656
<template #icon>
5757
<img
5858
class="app-menu__current-app-icon"
59+
:class="{ 'app-menu__current-app-icon--settings': currentApp.type === 'settings' }"
5960
:src="currentApp.icon"
6061
alt=""
6162
aria-hidden="true">
@@ -82,6 +83,9 @@ import IconDotsGrid from 'vue-material-design-icons/DotsGrid.vue'
8283
import AppItem from './AppItem.vue'
8384
import logger from '../logger.js'
8485
86+
// Settings IDs that represent actions, not navigable pages.
87+
const SETTINGS_ACTION_IDS = new Set(['logout'])
88+
8589
export default defineComponent({
8690
name: 'AppMenu',
8791
@@ -103,8 +107,12 @@ export default defineComponent({
103107
104108
data() {
105109
const appList = loadState<INavigationEntry[]>('core', 'apps', [])
110+
// Record<id, entry>, not an array: PHP ships getAll('settings') without
111+
// array_values(). Matches AccountMenu.vue's usage.
112+
const settingsList = loadState<Record<string, INavigationEntry>>('core', 'settingsNavEntries', {})
106113
return {
107114
appList,
115+
settingsList,
108116
isAdmin: getCurrentUser()?.isAdmin ?? false,
109117
// Roving tabindex: only this tile has tabindex=0; arrow keys move it.
110118
focusedIndex: 0,
@@ -146,7 +154,18 @@ export default defineComponent({
146154
147155
computed: {
148156
currentApp(): INavigationEntry | undefined {
157+
// Fall back to the active settings entry on admin pages where no
158+
// app is active.
149159
return this.appList.find((app) => app.active)
160+
?? Object.values(this.settingsList).find((entry) => entry.active && !SETTINGS_ACTION_IDS.has(entry.id))
161+
},
162+
163+
// aria-label overrides the inner span text, so the section name
164+
// has to be duplicated here for screen readers.
165+
currentAppLabel(): string {
166+
return this.currentApp
167+
? t('core', 'Open apps menu, currently in {app}', { app: this.currentApp.name })
168+
: t('core', 'Open apps menu')
150169
},
151170
152171
// Stable-ordered list that focusedIndex indexes into. The trailing
@@ -374,6 +393,12 @@ export default defineComponent({
374393
// Theme-aware inversion + vertical alpha fade via --header-menu-icon-mask.
375394
filter: var(--background-image-invert-if-bright);
376395
mask: var(--header-menu-icon-mask);
396+
397+
// Settings icons ship dark (designed for the white settings sidebar);
398+
// force-white so they read against the themed header.
399+
&--settings {
400+
filter: brightness(0) invert(1);
401+
}
377402
}
378403
379404
&__current-app-name {
@@ -386,16 +411,20 @@ export default defineComponent({
386411
&__popover {
387412
max-width: calc(100vw - var(--default-grid-baseline) * 4);
388413
background-color: var(--color-main-background);
414+
// Padding on the popover (not the grid) so the top-row hover sits
415+
// concentrically inside the rounded corners.
416+
padding: calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 2);
389417
}
390418
391419
&__grid {
392420
--app-item-col-width: 69px;
393421
--app-item-row-height: 64px;
394422
--app-menu-rows-visible: 6;
395-
padding: calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 2);
396423
display: grid;
397424
grid-template-columns: repeat(4, var(--app-item-col-width));
398425
grid-auto-rows: minmax(var(--app-item-row-height), max-content);
426+
// + baseline * 5: peek-row hint so users see that content continues
427+
// below the fold.
399428
max-height: calc(var(--app-item-row-height) * var(--app-menu-rows-visible) + var(--default-grid-baseline) * 5);
400429
overflow-y: auto;
401430

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,64 @@ describe('core: AppMenu', () => {
165165
const currentApp = wrapper.get('.app-menu__current-app').element
166166
expect(wrapper.vm.returnFocusTarget()).toBe(currentApp)
167167
})
168+
169+
it('falls back to the active settings entry when no app is active', () => {
170+
// Mimics being on /settings/admin/* where the active entry is registered
171+
// as type=settings (NavigationManager) and excluded from the `apps` list.
172+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
173+
if (key === 'apps') {
174+
return [makeApp({ id: 'files', name: 'Files', active: false })]
175+
}
176+
if (key === 'settingsNavEntries') {
177+
// Object keyed by entry id — matches PHP's serialization shape
178+
// (TemplateLayout ships the filtered associative array as-is).
179+
return {
180+
admin_settings: makeApp({
181+
id: 'admin_settings',
182+
name: 'Administration settings',
183+
type: 'settings',
184+
href: '/settings/admin/overview',
185+
icon: '/settings/img/admin.svg',
186+
active: true,
187+
}),
188+
}
189+
}
190+
return fallback
191+
})
192+
const wrapper = mount(AppMenu, { attachTo: document.body })
193+
expect(wrapper.find('.app-menu__current-app').exists()).toBe(true)
194+
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Administration settings')
195+
})
196+
197+
it('prefers the active app over a settings entry when both are marked active', () => {
198+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
199+
if (key === 'apps') {
200+
return [makeApp({ id: 'files', name: 'Files', active: true })]
201+
}
202+
if (key === 'settingsNavEntries') {
203+
return { admin_settings: makeApp({ id: 'admin_settings', name: 'Administration settings', type: 'settings', active: true }) }
204+
}
205+
return fallback
206+
})
207+
const wrapper = mount(AppMenu, { attachTo: document.body })
208+
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Files')
209+
})
210+
211+
it('does not render the current-app button when only the logout entry is active', () => {
212+
// Defensive: logout is an action, not a page, so it should never be the
213+
// "current section" even though it carries type=settings. NavigationManager
214+
// today never marks it active, but a future regression shouldn't leak a
215+
// "Log out" label into the header.
216+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
217+
if (key === 'apps') {
218+
return [makeApp({ id: 'files', name: 'Files', active: false })]
219+
}
220+
if (key === 'settingsNavEntries') {
221+
return { logout: makeApp({ id: 'logout', name: 'Log out', type: 'settings', href: '/logout', active: true }) }
222+
}
223+
return fallback
224+
})
225+
const wrapper = mount(AppMenu, { attachTo: document.body })
226+
expect(wrapper.find('.app-menu__current-app').exists()).toBe(false)
227+
})
168228
})

0 commit comments

Comments
 (0)