mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
feat(core): app menu polish for NC34
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
This commit is contained in:
parent
8c6b7f412d
commit
f401b29405
4 changed files with 100 additions and 6 deletions
|
|
@ -49,13 +49,14 @@
|
|||
v-if="currentApp"
|
||||
class="app-menu__current-app"
|
||||
variant="tertiary-no-background"
|
||||
:aria-label="t('core', 'Open apps menu')"
|
||||
:aria-label="currentAppLabel"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="opened ? 'true' : 'false'"
|
||||
@click="onTriggerClick('currentApp')">
|
||||
<template #icon>
|
||||
<img
|
||||
class="app-menu__current-app-icon"
|
||||
:class="{ 'app-menu__current-app-icon--settings': currentApp.type === 'settings' }"
|
||||
:src="currentApp.icon"
|
||||
alt=""
|
||||
aria-hidden="true">
|
||||
|
|
@ -82,6 +83,9 @@ import IconDotsGrid from 'vue-material-design-icons/DotsGrid.vue'
|
|||
import AppItem from './AppItem.vue'
|
||||
import logger from '../logger.js'
|
||||
|
||||
// Settings IDs that represent actions, not navigable pages.
|
||||
const SETTINGS_ACTION_IDS = new Set(['logout'])
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppMenu',
|
||||
|
||||
|
|
@ -103,8 +107,12 @@ export default defineComponent({
|
|||
|
||||
data() {
|
||||
const appList = loadState<INavigationEntry[]>('core', 'apps', [])
|
||||
// Record<id, entry>, not an array: PHP ships getAll('settings') without
|
||||
// array_values(). Matches AccountMenu.vue's usage.
|
||||
const settingsList = loadState<Record<string, INavigationEntry>>('core', 'settingsNavEntries', {})
|
||||
return {
|
||||
appList,
|
||||
settingsList,
|
||||
isAdmin: getCurrentUser()?.isAdmin ?? false,
|
||||
// Roving tabindex: only this tile has tabindex=0; arrow keys move it.
|
||||
focusedIndex: 0,
|
||||
|
|
@ -146,7 +154,18 @@ export default defineComponent({
|
|||
|
||||
computed: {
|
||||
currentApp(): INavigationEntry | undefined {
|
||||
// Fall back to the active settings entry on admin pages where no
|
||||
// app is active.
|
||||
return this.appList.find((app) => app.active)
|
||||
?? Object.values(this.settingsList).find((entry) => entry.active && !SETTINGS_ACTION_IDS.has(entry.id))
|
||||
},
|
||||
|
||||
// aria-label overrides the inner span text, so the section name
|
||||
// has to be duplicated here for screen readers.
|
||||
currentAppLabel(): string {
|
||||
return this.currentApp
|
||||
? t('core', 'Open apps menu, currently in {app}', { app: this.currentApp.name })
|
||||
: t('core', 'Open apps menu')
|
||||
},
|
||||
|
||||
// Stable-ordered list that focusedIndex indexes into. The trailing
|
||||
|
|
@ -374,6 +393,12 @@ export default defineComponent({
|
|||
// Theme-aware inversion + vertical alpha fade via --header-menu-icon-mask.
|
||||
filter: var(--background-image-invert-if-bright);
|
||||
mask: var(--header-menu-icon-mask);
|
||||
|
||||
// Settings icons ship dark (designed for the white settings sidebar);
|
||||
// force-white so they read against the themed header.
|
||||
&--settings {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
}
|
||||
|
||||
&__current-app-name {
|
||||
|
|
@ -392,13 +417,22 @@ export default defineComponent({
|
|||
--app-item-col-width: 69px;
|
||||
--app-item-row-height: 64px;
|
||||
--app-menu-rows-visible: 6;
|
||||
padding: calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 2);
|
||||
padding: calc(var(--default-grid-baseline) * 2);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, var(--app-item-col-width));
|
||||
grid-auto-rows: minmax(var(--app-item-row-height), max-content);
|
||||
max-height: calc(var(--app-item-row-height) * var(--app-menu-rows-visible) + var(--default-grid-baseline) * 5);
|
||||
// + baseline * 5: peek-row hint so users see that content continues
|
||||
// below the fold.
|
||||
max-height: calc(var(--app-item-row-height) * var(--app-menu-rows-visible) + var(--default-grid-baseline) * 7);
|
||||
overflow-y: auto;
|
||||
|
||||
// Extra top padding on first-row tiles so the hover bg reads
|
||||
// concentric with the popover's rounded top corner. !important
|
||||
// because AppItem's scoped rule has the same specificity.
|
||||
> :nth-child(-n+4) {
|
||||
padding-block-start: calc(var(--default-grid-baseline) * 2) !important;
|
||||
}
|
||||
|
||||
// WebKit equivalents are in the unscoped block below: scoped CSS
|
||||
// data-attrs don't reach ::-webkit-scrollbar pseudo-elements in Chrome.
|
||||
scrollbar-width: thin;
|
||||
|
|
|
|||
|
|
@ -165,4 +165,64 @@ describe('core: AppMenu', () => {
|
|||
const currentApp = wrapper.get('.app-menu__current-app').element
|
||||
expect(wrapper.vm.returnFocusTarget()).toBe(currentApp)
|
||||
})
|
||||
|
||||
it('falls back to the active settings entry when no app is active', () => {
|
||||
// Mimics being on /settings/admin/* where the active entry is registered
|
||||
// as type=settings (NavigationManager) and excluded from the `apps` list.
|
||||
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
|
||||
if (key === 'apps') {
|
||||
return [makeApp({ id: 'files', name: 'Files', active: false })]
|
||||
}
|
||||
if (key === 'settingsNavEntries') {
|
||||
// Object keyed by entry id — matches PHP's serialization shape
|
||||
// (TemplateLayout ships the filtered associative array as-is).
|
||||
return {
|
||||
admin_settings: makeApp({
|
||||
id: 'admin_settings',
|
||||
name: 'Administration settings',
|
||||
type: 'settings',
|
||||
href: '/settings/admin/overview',
|
||||
icon: '/settings/img/admin.svg',
|
||||
active: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
})
|
||||
const wrapper = mount(AppMenu, { attachTo: document.body })
|
||||
expect(wrapper.find('.app-menu__current-app').exists()).toBe(true)
|
||||
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Administration settings')
|
||||
})
|
||||
|
||||
it('prefers the active app over a settings entry when both are marked active', () => {
|
||||
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
|
||||
if (key === 'apps') {
|
||||
return [makeApp({ id: 'files', name: 'Files', active: true })]
|
||||
}
|
||||
if (key === 'settingsNavEntries') {
|
||||
return { admin_settings: makeApp({ id: 'admin_settings', name: 'Administration settings', type: 'settings', active: true }) }
|
||||
}
|
||||
return fallback
|
||||
})
|
||||
const wrapper = mount(AppMenu, { attachTo: document.body })
|
||||
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Files')
|
||||
})
|
||||
|
||||
it('does not render the current-app button when only the logout entry is active', () => {
|
||||
// Defensive: logout is an action, not a page, so it should never be the
|
||||
// "current section" even though it carries type=settings. NavigationManager
|
||||
// today never marks it active, but a future regression shouldn't leak a
|
||||
// "Log out" label into the header.
|
||||
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
|
||||
if (key === 'apps') {
|
||||
return [makeApp({ id: 'files', name: 'Files', active: false })]
|
||||
}
|
||||
if (key === 'settingsNavEntries') {
|
||||
return { logout: makeApp({ id: 'logout', name: 'Log out', type: 'settings', href: '/logout', active: true }) }
|
||||
}
|
||||
return fallback
|
||||
})
|
||||
const wrapper = mount(AppMenu, { attachTo: document.body })
|
||||
expect(wrapper.find('.app-menu__current-app').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
4
dist/core-main.js
vendored
4
dist/core-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-main.js.map
vendored
2
dist/core-main.js.map
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue