Merge pull request #60180 from nextcloud/feat/59888/waffle-menu

feat(core): app menu waffle launcher
This commit is contained in:
Louis 2026-05-07 21:50:35 +02:00 committed by GitHub
commit f501b442a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 954 additions and 382 deletions

View file

@ -2,4 +2,4 @@
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/#skip-actions{position:absolute;overflow:hidden;z-index:9999;top:-999px;inset-inline-start:3px;padding:11px;display:flex;flex-wrap:wrap;gap:11px}#skip-actions:focus-within{top:var(--header-height)}#header{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#header:not(.header-guest){display:inline-flex;position:absolute;top:0;width:100%;z-index:2000;height:var(--header-height);box-sizing:border-box;justify-content:space-between}#header #nextcloud{padding:5px 0;padding-inline-start:86px;position:relative;height:calc(100% - var(--default-grid-baseline));box-sizing:border-box;opacity:1;align-items:center;display:flex;flex-wrap:wrap;overflow:hidden;margin:2px}#header #nextcloud:hover,#header #nextcloud:active{opacity:1}#header #nextcloud .logo{display:inline-flex;background-image:var(--image-logoheader, var(--image-logo, url("../img/logo/logo.svg")));background-repeat:no-repeat;background-size:contain;background-position:center;width:62px;position:absolute;inset-inline-start:12px;top:1px;bottom:1px;filter:var(--image-logoheader-custom, var(--background-image-invert-if-bright))}#header #nextcloud:focus-visible,#header .app-menu-entry a:focus-visible,#header .header-menu button:first-of-type:focus-visible{outline:none}#header #nextcloud:focus-visible::after,#header .app-menu-entry a:focus-visible::after,#header .header-menu button:first-of-type:focus-visible::after{content:" ";position:absolute;inset-block-end:2px;transform:translateX(-50%);width:12px;height:2px;border-radius:3px;background-color:var(--color-background-plain-text);inset-inline-start:50%;opacity:1}#header .header-start{display:inline-flex;align-items:center;flex:1 0;white-space:nowrap;min-width:0}#header .header-end{display:inline-flex;align-items:center;justify-content:flex-end;flex-shrink:1;margin-inline-end:calc(3*var(--default-grid-baseline))}#header .header-appname{color:var(--color-background-plain-text);font-size:16px;font-weight:bold;margin:0;padding:0;padding-inline-end:5px;overflow:hidden;text-overflow:ellipsis;flex:1 1 100%}#header .header-appname .header-info{display:flex;flex-direction:column;overflow:hidden}#header .header-appname .header-info .header-title{overflow:hidden;text-overflow:ellipsis}#header .header-appname .header-info .header-shared-by{color:var(--color-background-plain-text);position:relative;font-weight:300;font-size:var(--font-size-small);line-height:var(--font-size-small);overflow:hidden;text-overflow:ellipsis}/*# sourceMappingURL=header.css.map */
*/#skip-actions{position:absolute;overflow:hidden;z-index:9999;top:-999px;inset-inline-start:3px;padding:11px;display:flex;flex-wrap:wrap;gap:11px}#skip-actions:focus-within{top:var(--header-height)}#header{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#header:not(.header-guest){display:inline-flex;position:absolute;top:0;width:100%;z-index:2000;height:var(--header-height);box-sizing:border-box;justify-content:space-between}#header #nextcloud{padding:5px 0;padding-inline-start:86px;position:relative;height:calc(100% - var(--default-grid-baseline));box-sizing:border-box;opacity:1;align-items:center;display:flex;flex-wrap:wrap;overflow:hidden;margin:2px}#header #nextcloud:hover,#header #nextcloud:active{opacity:1}#header #nextcloud .logo{display:inline-flex;background-image:var(--image-logoheader, var(--image-logo, url("../img/logo/logo.svg")));background-repeat:no-repeat;background-size:contain;background-position:center;width:62px;position:absolute;inset-inline-start:12px;top:1px;bottom:1px;filter:var(--image-logoheader-custom, var(--background-image-invert-if-bright))}#header #nextcloud:focus-visible,#header .header-menu button:first-of-type:focus-visible{outline:none}#header #nextcloud:focus-visible::after,#header .header-menu button:first-of-type:focus-visible::after{content:" ";position:absolute;inset-block-end:2px;transform:translateX(-50%);width:12px;height:2px;border-radius:3px;background-color:var(--color-background-plain-text);inset-inline-start:50%;opacity:1}#header .header-start{display:inline-flex;align-items:center;flex:1 0;white-space:nowrap;min-width:0}#header .header-end{display:inline-flex;align-items:center;justify-content:flex-end;flex-shrink:1;margin-inline-end:calc(3*var(--default-grid-baseline))}#header .header-appname{color:var(--color-background-plain-text);font-size:16px;font-weight:bold;margin:0;padding:0;padding-inline-end:5px;overflow:hidden;text-overflow:ellipsis;flex:1 1 100%}#header .header-appname .header-info{display:flex;flex-direction:column;overflow:hidden}#header .header-appname .header-info .header-title{overflow:hidden;text-overflow:ellipsis}#header .header-appname .header-info .header-shared-by{color:var(--color-background-plain-text);position:relative;font-weight:300;font-size:var(--font-size-small);line-height:var(--font-size-small);overflow:hidden;text-overflow:ellipsis}/*# sourceMappingURL=header.css.map */

View file

@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["header.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA;AAAA,GAYA,cACC,kBACA,gBACA,aACA,WACA,uBACA,aACA,aACA,eACA,SAEA,2BACC,yBAKF,QAEC,yBACA,sBACA,qBACA,iBAGA,2BACC,oBACA,kBACA,MACA,WACA,aACA,4BACA,sBACA,8BAID,mBACC,cACA,0BACA,kBACA,iDACA,sBACA,UACA,mBACA,aACA,eACA,gBACA,WAEA,mDACC,UAID,yBACC,oBACA,yFACA,4BACA,wBACA,2BACA,WACA,kBACA,wBACA,QACA,WAEA,gFAMF,iIAGC,aAEA,sJACC,YACA,kBACA,oBACA,2BACA,WACA,WACA,kBACA,oDACA,uBACA,UAOF,sBACC,oBACA,mBACA,SACA,mBACA,YAKD,oBACC,oBACA,mBACA,yBACA,cAEA,uDAKD,wBACC,yCACA,eACA,iBACA,SACA,UACA,uBACA,gBACA,uBAEA,cAGA,qCACC,aACA,sBACA,gBAEA,mDACC,gBACA,uBAGD,uDACC,yCACA,kBACA,gBACA,iCACA,mCACA,gBACA","file":"header.css"}
{"version":3,"sourceRoot":"","sources":["header.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA;AAAA,GAYA,cACC,kBACA,gBACA,aACA,WACA,uBACA,aACA,aACA,eACA,SAEA,2BACC,yBAKF,QAEC,yBACA,sBACA,qBACA,iBAGA,2BACC,oBACA,kBACA,MACA,WACA,aACA,4BACA,sBACA,8BAID,mBACC,cACA,0BACA,kBACA,iDACA,sBACA,UACA,mBACA,aACA,eACA,gBACA,WAEA,mDACC,UAID,yBACC,oBACA,yFACA,4BACA,wBACA,2BACA,WACA,kBACA,wBACA,QACA,WAEA,gFAMF,yFAEC,aAEA,uGACC,YACA,kBACA,oBACA,2BACA,WACA,WACA,kBACA,oDACA,uBACA,UAOF,sBACC,oBACA,mBACA,SACA,mBACA,YAKD,oBACC,oBACA,mBACA,yBACA,cAEA,uDAKD,wBACC,yCACA,eACA,iBACA,SACA,UACA,uBACA,gBACA,uBAEA,cAGA,qCACC,aACA,sBACA,gBAEA,mDACC,gBACA,uBAGD,uDACC,yCACA,kBACA,gBACA,iCACA,mCACA,gBACA","file":"header.css"}

View file

@ -84,7 +84,6 @@
// focus visible styles
// this adds a small line below all entries when visually focussed
#nextcloud:focus-visible,
.app-menu-entry a:focus-visible,
.header-menu button:first-of-type:focus-visible {
outline: none;

View file

@ -22,7 +22,7 @@
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/#skip-actions{position:absolute;overflow:hidden;z-index:9999;top:-999px;inset-inline-start:3px;padding:11px;display:flex;flex-wrap:wrap;gap:11px}#skip-actions:focus-within{top:var(--header-height)}#header{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#header:not(.header-guest){display:inline-flex;position:absolute;top:0;width:100%;z-index:2000;height:var(--header-height);box-sizing:border-box;justify-content:space-between}#header #nextcloud{padding:5px 0;padding-inline-start:86px;position:relative;height:calc(100% - var(--default-grid-baseline));box-sizing:border-box;opacity:1;align-items:center;display:flex;flex-wrap:wrap;overflow:hidden;margin:2px}#header #nextcloud:hover,#header #nextcloud:active{opacity:1}#header #nextcloud .logo{display:inline-flex;background-image:var(--image-logoheader, var(--image-logo, url("../img/logo/logo.svg")));background-repeat:no-repeat;background-size:contain;background-position:center;width:62px;position:absolute;inset-inline-start:12px;top:1px;bottom:1px;filter:var(--image-logoheader-custom, var(--background-image-invert-if-bright))}#header #nextcloud:focus-visible,#header .app-menu-entry a:focus-visible,#header .header-menu button:first-of-type:focus-visible{outline:none}#header #nextcloud:focus-visible::after,#header .app-menu-entry a:focus-visible::after,#header .header-menu button:first-of-type:focus-visible::after{content:" ";position:absolute;inset-block-end:2px;transform:translateX(-50%);width:12px;height:2px;border-radius:3px;background-color:var(--color-background-plain-text);inset-inline-start:50%;opacity:1}#header .header-start{display:inline-flex;align-items:center;flex:1 0;white-space:nowrap;min-width:0}#header .header-end{display:inline-flex;align-items:center;justify-content:flex-end;flex-shrink:1;margin-inline-end:calc(3*var(--default-grid-baseline))}#header .header-appname{color:var(--color-background-plain-text);font-size:16px;font-weight:bold;margin:0;padding:0;padding-inline-end:5px;overflow:hidden;text-overflow:ellipsis;flex:1 1 100%}#header .header-appname .header-info{display:flex;flex-direction:column;overflow:hidden}#header .header-appname .header-info .header-title{overflow:hidden;text-overflow:ellipsis}#header .header-appname .header-info .header-shared-by{color:var(--color-background-plain-text);position:relative;font-weight:300;font-size:var(--font-size-small);line-height:var(--font-size-small);overflow:hidden;text-overflow:ellipsis}/*!
*/#skip-actions{position:absolute;overflow:hidden;z-index:9999;top:-999px;inset-inline-start:3px;padding:11px;display:flex;flex-wrap:wrap;gap:11px}#skip-actions:focus-within{top:var(--header-height)}#header{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#header:not(.header-guest){display:inline-flex;position:absolute;top:0;width:100%;z-index:2000;height:var(--header-height);box-sizing:border-box;justify-content:space-between}#header #nextcloud{padding:5px 0;padding-inline-start:86px;position:relative;height:calc(100% - var(--default-grid-baseline));box-sizing:border-box;opacity:1;align-items:center;display:flex;flex-wrap:wrap;overflow:hidden;margin:2px}#header #nextcloud:hover,#header #nextcloud:active{opacity:1}#header #nextcloud .logo{display:inline-flex;background-image:var(--image-logoheader, var(--image-logo, url("../img/logo/logo.svg")));background-repeat:no-repeat;background-size:contain;background-position:center;width:62px;position:absolute;inset-inline-start:12px;top:1px;bottom:1px;filter:var(--image-logoheader-custom, var(--background-image-invert-if-bright))}#header #nextcloud:focus-visible,#header .header-menu button:first-of-type:focus-visible{outline:none}#header #nextcloud:focus-visible::after,#header .header-menu button:first-of-type:focus-visible::after{content:" ";position:absolute;inset-block-end:2px;transform:translateX(-50%);width:12px;height:2px;border-radius:3px;background-color:var(--color-background-plain-text);inset-inline-start:50%;opacity:1}#header .header-start{display:inline-flex;align-items:center;flex:1 0;white-space:nowrap;min-width:0}#header .header-end{display:inline-flex;align-items:center;justify-content:flex-end;flex-shrink:1;margin-inline-end:calc(3*var(--default-grid-baseline))}#header .header-appname{color:var(--color-background-plain-text);font-size:16px;font-weight:bold;margin:0;padding:0;padding-inline-end:5px;overflow:hidden;text-overflow:ellipsis;flex:1 1 100%}#header .header-appname .header-info{display:flex;flex-direction:column;overflow:hidden}#header .header-appname .header-info .header-title{overflow:hidden;text-overflow:ellipsis}#header .header-appname .header-info .header-shared-by{color:var(--color-background-plain-text);position:relative;font-weight:300;font-size:var(--font-size-small);line-height:var(--font-size-small);overflow:hidden;text-overflow:ellipsis}/*!
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,174 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<a
class="app-item"
:class="{
'app-item--active': app.active,
'app-item--outlined': outlined,
}"
:href="app.href"
:target="newTab ? '_blank' : undefined"
:rel="newTab ? 'noopener noreferrer' : undefined"
:aria-current="app.active ? 'page' : undefined"
:tabindex="tabindex"
:title="app.name"
role="menuitem">
<span class="app-item__circle">
<img
class="app-item__icon"
:src="app.icon"
alt=""
aria-hidden="true">
<span
v-if="app.unread"
class="app-item__unread"
aria-hidden="true" />
</span>
<span class="app-item__label">
{{ app.name }}
<span v-if="app.unread" class="hidden-visually">, {{ unreadLabel }}</span>
</span>
</a>
</template>
<script setup lang="ts">
import type { INavigationEntry } from '../types/navigation.d.ts'
import { n } from '@nextcloud/l10n'
import { computed } from 'vue'
const props = withDefaults(defineProps<{
app: INavigationEntry
/** When true, the link opens in a new tab with rel="noopener noreferrer". Used for external destinations (e.g. the app store). */
newTab?: boolean
/** When true, render the circle as an outline only (used for "More apps" / utility entries). */
outlined?: boolean
/**
* Roving-tabindex value. AppMenu sets this to 0 on the focused tile and
* -1 on all other tiles so only one stop is in the natural Tab order.
* Default -1 keeps tiles out of the Tab order when used standalone.
*/
tabindex?: number
}>(), {
tabindex: -1,
})
const unreadLabel = computed(() => {
if (!props.app.unread) {
return undefined
}
return n(
'core',
'{count} notification',
'{count} notifications',
props.app.unread,
{ count: props.app.unread },
)
})
</script>
<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;
gap: var(--default-grid-baseline);
// Inset so the hover/focus highlight floats around the circle and label
// rather than sitting flush against the icon at the top edge.
padding-block: var(--default-grid-baseline);
border-radius: var(--border-radius-element);
text-decoration: none;
color: var(--color-main-text);
min-width: 0;
&:hover,
&:focus-visible {
background-color: var(--color-background-hover);
}
// Inset ring instead of outline + offset: the offset version visibly
// clips at the popover's rounded edge for items in the first/last row
// or column. The inset shadow stays inside the highlight rectangle.
&:focus-visible {
outline: none;
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);
// Force the icon to white on the colored circle, then apply the
// same vertical alpha gradient (--header-menu-icon-mask) used in
// the header so icons read consistently across the design.
filter: brightness(0) invert(1);
mask: var(--header-menu-icon-mask);
}
&__unread {
position: absolute;
top: 0;
inset-inline-end: 0;
width: calc(var(--default-grid-baseline) * 3);
height: calc(var(--default-grid-baseline) * 3);
border-radius: 50%;
background-color: var(--color-error);
border: 2px solid var(--color-main-background);
box-sizing: content-box;
}
&__label {
font-size: 12px;
line-height: 1.3;
text-align: center;
color: var(--color-main-text);
word-break: normal;
overflow-wrap: break-word;
max-width: 100%;
letter-spacing: -0.3px;
}
&--active &__label {
font-weight: bold;
}
// Outlined variant: no fill or gradient; icon color is unforced.
&--outlined &__circle {
background: transparent;
background-image: none;
box-shadow: inset 0 0 0 2px var(--color-border-maxcontrast);
}
&--outlined &__icon {
filter: brightness(0);
mask: none;
}
}
</style>

View file

@ -4,62 +4,100 @@
-->
<template>
<nav
ref="appMenu"
class="app-menu"
:aria-label="t('core', 'Applications menu')">
<ul
:aria-label="t('core', 'Apps')"
class="app-menu__list">
<AppMenuEntry
v-for="app in mainAppList"
:key="app.id"
:app="app" />
</ul>
<NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')">
<NcActionLink
v-for="app in popoverAppList"
:key="app.id"
:aria-current="app.active ? 'page' : false"
:href="app.href"
:icon="app.icon"
class="app-menu__overflow-entry">
{{ app.name }}
</NcActionLink>
</NcActions>
<nav class="app-menu" :aria-label="t('core', 'Applications')">
<NcPopover
ref="popover"
:shown="opened"
:triggers="[]"
placement="bottom-start"
:skidding="popoverSkidding"
:setReturnFocus="returnFocusTarget"
popoverBaseClass="app-menu__popover-base"
popupRole="menu"
@update:shown="opened = $event">
<template #trigger>
<NcButton
class="app-menu__waffle"
variant="tertiary-no-background"
:aria-label="t('core', 'Open apps menu')"
aria-haspopup="menu"
:aria-expanded="opened ? 'true' : 'false'"
@click="onTriggerClick('waffle')">
<template #icon>
<IconDotsGrid :size="20" />
</template>
</NcButton>
</template>
<div
class="app-menu__popover"
role="menu"
:aria-label="t('core', 'Apps')">
<div class="app-menu__grid" @keydown="onGridKeydown">
<AppItem
v-for="(item, i) in gridItems"
:key="item.id"
ref="items"
:app="item"
:outlined="item.id === 'more-apps' || item.id === 'app-store'"
:newTab="item.id === 'app-store'"
:tabindex="i === focusedIndex ? 0 : -1" />
</div>
</div>
</NcPopover>
<NcButton
v-if="currentApp"
class="app-menu__current-app"
variant="tertiary-no-background"
:aria-label="t('core', 'Open apps menu')"
aria-haspopup="menu"
:aria-expanded="opened ? 'true' : 'false'"
@click="onTriggerClick('currentApp')">
<template #icon>
<img
class="app-menu__current-app-icon"
:src="currentApp.icon"
alt=""
aria-hidden="true">
</template>
<span class="app-menu__current-app-name">
{{ currentApp.name }}
</span>
</NcButton>
</nav>
</template>
<script lang="ts">
import type { INavigationEntry } from '../types/navigation.d.ts'
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { n, t } from '@nextcloud/l10n'
import { useElementSize } from '@vueuse/core'
import { isRTL, n, t } from '@nextcloud/l10n'
import { generateFilePath, generateUrl } from '@nextcloud/router'
import { defineComponent, ref } from 'vue'
import NcActionLink from '@nextcloud/vue/components/NcActionLink'
import NcActions from '@nextcloud/vue/components/NcActions'
import AppMenuEntry from './AppMenuEntry.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconDotsGrid from 'vue-material-design-icons/DotsGrid.vue'
import AppItem from './AppItem.vue'
import logger from '../logger.js'
export default defineComponent({
name: 'AppMenu',
components: {
AppMenuEntry,
NcActions,
NcActionLink,
AppItem,
IconDotsGrid,
NcButton,
NcPopover,
},
setup() {
const appMenu = ref()
const { width: appMenuWidth } = useElementSize(appMenu)
const opened = ref(false)
return {
t,
n,
appMenu,
appMenuWidth,
opened,
}
},
@ -67,41 +105,107 @@ export default defineComponent({
const appList = loadState<INavigationEntry[]>('core', 'apps', [])
return {
appList,
isAdmin: getCurrentUser()?.isAdmin ?? false,
// Roving tabindex: only this tile has tabindex=0; arrow keys move it.
focusedIndex: 0,
// NcPopover's focus-trap only knows the slot trigger (waffle).
// The current-app button lives outside the slot, so we track the
// source and restore focus manually via setReturnFocus.
openedFrom: null as 'waffle' | 'currentApp' | null,
// Synthetic tile appended to the grid: admins jump to the local
// app management page; everyone else lands on apps.nextcloud.com
// (external, opens in a new tab via the per-tile newTab flag).
moreAppsEntry: {
id: 'more-apps',
active: false,
order: Number.MAX_SAFE_INTEGER,
href: generateUrl('/settings/apps'),
icon: generateFilePath('settings', 'img', 'settings_apps.svg'),
type: 'link',
name: t('core', 'More apps'),
unread: 0,
} as INavigationEntry,
appStoreEntry: {
id: 'app-store',
active: false,
order: Number.MAX_SAFE_INTEGER,
href: 'https://apps.nextcloud.com/',
icon: generateFilePath('settings', 'img', 'apps.svg'),
type: 'link',
name: t('core', 'App store'),
unread: 0,
} as INavigationEntry,
// `placement: bottom-start` swaps the anchor edge under RTL but the
// skidding sign isn't auto-mirrored, so we flip it here. Snapshot
// at init: Nextcloud's language doesn't change at runtime.
popoverSkidding: isRTL() ? 82 : -82,
}
},
computed: {
appLimit() {
const maxApps = Math.floor(this.appMenuWidth / 50)
if (maxApps < this.appList.length) {
// Ensure there is space for the overflow menu
return Math.max(maxApps - 1, 0)
currentApp(): INavigationEntry | undefined {
return this.appList.find((app) => app.active)
},
// Stable-ordered list that focusedIndex indexes into. The trailing
// utility tile is "More apps" (local app management) for admins and
// "App store" (apps.nextcloud.com) for everyone else.
gridItems(): INavigationEntry[] {
const tail = this.isAdmin ? this.moreAppsEntry : this.appStoreEntry
return [...this.appList, tail]
},
},
watch: {
// On open, land the roving stop on the active app rather than index 0.
opened(isOpen: boolean) {
if (isOpen) {
this.focusedIndex = this.activeGridIndex()
}
return maxApps
},
mainAppList() {
return this.appList.slice(0, this.appLimit)
},
popoverAppList() {
return this.appList.slice(this.appLimit)
},
},
mounted() {
subscribe('nextcloud:app-menu.refresh', this.setApps)
// Pre-seed so the correct tile has tabindex=0 before first open.
this.focusedIndex = this.activeGridIndex()
// Use $on instead of a template listener: the codebase lint rule forbids
// hyphenated v-on names, and Vue 2 doesn't normalize kebab-case to
// camelCase, so @afterHide on a NcPopover v8 event never fires.
;(this.$refs.popover as { $on: (e: string, fn: () => void) => void }).$on('after-hide', this.onPopoverAfterHide)
},
beforeDestroy() {
beforeUnmount() {
unsubscribe('nextcloud:app-menu.refresh', this.setApps)
;(this.$refs.popover as { $off: (e: string, fn: () => void) => void } | undefined)?.$off('after-hide', this.onPopoverAfterHide)
},
methods: {
// focus-trap calls this on deactivation. NcPopover defaults to the
// slot trigger (waffle); we override so current-app opens return
// there instead. Waffle is the fallback since current-app only
// renders when an app is active.
returnFocusTarget(): HTMLElement | null {
return this.openedFrom === 'currentApp'
? this.$el.querySelector('.app-menu__current-app')
: this.$el.querySelector('.app-menu__waffle')
},
onPopoverAfterHide() {
this.openedFrom = null
},
onTriggerClick(source: 'waffle' | 'currentApp') {
this.openedFrom = source
this.opened = !this.opened
},
setNavigationCounter(id: string, counter: number) {
const app = this.appList.find(({ app }) => app === id)
if (app) {
this.$set(app, 'unread', counter)
app.unread = counter
} else {
logger.warn(`Could not find app "${id}" for setting navigation count`)
}
@ -109,6 +213,100 @@ export default defineComponent({
setApps({ apps }: { apps: INavigationEntry[] }) {
this.appList = apps
if (this.focusedIndex >= this.gridItems.length) {
this.focusedIndex = this.activeGridIndex()
}
},
// Index of the active app within `gridItems`, or 0 if none is active.
activeGridIndex(): number {
const idx = this.gridItems.findIndex((app) => app.active)
return idx === -1 ? 0 : idx
},
// Roving-tabindex keyboard contract for the launcher grid.
// Arrow keys clamp at edges (no wrap), matching the WAI-ARIA grid
// pattern. Tab is intentionally NOT handled so the browser's native
// focus order moves out of the grid.
async onGridKeydown(event: KeyboardEvent) {
// Let modifier-bearing key combos fall through to the browser.
// Shift is included so Shift+Enter opens the link in a new tab
// via the browser's native modifier-aware <a> activation.
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) {
return
}
if (this.gridItems.length === 0) {
return
}
const cols = 4
const total = this.gridItems.length
const i = this.focusedIndex
let next = i
switch (event.key) {
case 'ArrowRight': {
// Clamp at the row's right edge; never wrap to the next row.
const atRowEnd = (i % cols) === cols - 1
if (!atRowEnd && i + 1 < total) {
next = i + 1
}
break
}
case 'ArrowLeft': {
const atRowStart = (i % cols) === 0
if (!atRowStart) {
next = i - 1
}
break
}
case 'ArrowDown': {
if (i + cols < total) {
next = i + cols
}
break
}
case 'ArrowUp': {
if (i - cols >= 0) {
next = i - cols
}
break
}
case 'Home':
next = 0
break
case 'End':
next = total - 1
break
case 'Enter':
case ' ': {
// Space's default scrolls the nearest scrollable ancestor (the
// popover); intercept and click programmatically. Enter gets
// the same treatment so we can close the popover uniformly.
const items = this.$refs.items as Array<{ $el: HTMLElement }> | undefined
items?.[this.focusedIndex]?.$el?.click()
this.opened = false
event.preventDefault()
event.stopPropagation()
return
}
default:
// Tab and every other key falls through untouched.
return
}
// Stop bubbling to document-level handlers (e.g. the Files app's
// keyboard shortcuts) that would also act on arrow keys.
event.preventDefault()
event.stopPropagation()
if (next !== i) {
this.focusedIndex = next
}
await this.$nextTick()
const items = this.$refs.items as Array<{ $el: HTMLElement }> | undefined
items?.[this.focusedIndex]?.$el?.focus()
},
},
})
@ -116,49 +314,141 @@ export default defineComponent({
<style scoped lang="scss">
.app-menu {
// The size the currently focussed entry will grow to show the full name
--app-menu-entry-growth: calc(var(--default-grid-baseline) * 4);
display: flex;
flex: 1 1;
width: 0;
align-items: center;
&__list {
display: flex;
flex-wrap: nowrap;
margin-inline: calc(var(--app-menu-entry-growth) / 2);
}
&__waffle {
// NcButton's tertiary-no-background variant uses --color-main-text,
// which is dark on light themes. The header sits on the theme primary
// background, so override to use the matching plain-text color.
--color-main-text: var(--color-background-plain-text);
color: var(--color-background-plain-text);
&__overflow {
margin-block: auto;
// Class merges onto NcButton's root <button>; style directly, no :deep().
// !important: v8 NcButton's legacy bundle sets focus-visible
// outline/box-shadow with !important, same as the current-app :active rule.
&:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.1) !important;
}
// Adjust the overflow NcActions styles as they are directly rendered on the background
:deep(.button-vue--vue-tertiary) {
opacity: .7;
margin: 3px;
filter: var(--background-image-invert-if-bright);
&:active:not(:disabled) {
background-color: rgba(0, 0, 0, 0.15) !important;
}
/* Remove all background and align text color if not expanded */
&:not([aria-expanded="true"]) {
color: var(--color-background-plain-text);
&:hover {
opacity: 1;
background-color: transparent !important;
}
}
&:focus-visible {
opacity: 1;
outline: none !important;
}
&:focus-visible {
background-color: rgba(0, 0, 0, 0.1) !important;
outline: none !important;
box-shadow: inset 0 0 0 2px var(--color-background-plain-text) !important;
}
}
&__overflow-entry {
:deep(.action-link__icon) {
// Icons are bright so invert them if bright color theme == bright background is used
filter: var(--background-invert-if-bright) !important;
&__current-app {
// NcButton's tertiary-no-background variant uses --color-main-text,
// which is dark on light themes. The header sits on the theme primary
// background, so override to use the matching plain-text color.
--color-main-text: var(--color-background-plain-text);
color: var(--color-background-plain-text);
// !important: v8 NcButton's legacy bundle sets focus-visible
// outline/box-shadow with !important. Same translucent-black hover/
// active overlays as the waffle: --color-background-hover collapses
// contrast against the theme-primary header tint.
&:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.1) !important;
}
&:active:not(:disabled) {
background-color: rgba(0, 0, 0, 0.15) !important;
}
&:focus-visible {
background-color: rgba(0, 0, 0, 0.1) !important;
outline: none !important;
box-shadow: inset 0 0 0 2px var(--color-background-plain-text) !important;
}
}
&__current-app-icon {
width: calc(var(--default-grid-baseline) * 5);
height: calc(var(--default-grid-baseline) * 5);
// Theme-aware inversion + vertical alpha fade via --header-menu-icon-mask.
filter: var(--background-image-invert-if-bright);
mask: var(--header-menu-icon-mask);
}
&__current-app-name {
font-size: var(--default-font-size);
font-weight: 500;
white-space: nowrap;
letter-spacing: -0.5px;
}
&__popover {
max-width: calc(100vw - var(--default-grid-baseline) * 4);
background-color: var(--color-main-background);
}
&__grid {
--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);
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);
overflow-y: auto;
// WebKit equivalents are in the unscoped block below: scoped CSS
// data-attrs don't reach ::-webkit-scrollbar pseudo-elements in Chrome.
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
}
</style>
<!-- Teleported content; scoped styles can't reach it. NcPopover v8 reads
--border-radius-large; v9 reads --border-radius-element. Set both for forward-compat. -->
<style lang="scss">
.app-menu__popover-base {
--border-radius-large: var(--border-radius-container-large);
--border-radius-element: var(--border-radius-container-large);
}
// Gap between the trigger and the popover. Floating-ui positions
// .v-popper__popper, so margin on its inner .v-popper__wrapper isn't
// recomputed. Used instead of NcPopover's :distance prop, which isn't
// exposed in the released @nextcloud/vue yet.
.app-menu__popover-base .v-popper__wrapper {
margin-block-start: -1px;
}
// Without this reset the override above cascades into AppItem and inflates
// its hover radius. Restores the system default from apps/theming/css/default.css.
.app-menu__popover-base .app-menu__popover {
--border-radius-element: 8px;
}
// Outside the scoped block: ::-webkit-scrollbar pseudo-elements need unscoped
// CSS to bind in Chrome. !important: core/css/styles.scss forces a 12 px thumb.
.app-menu__popover-base .app-menu__grid {
scrollbar-width: thin !important;
scrollbar-color: var(--color-scrollbar) transparent !important;
&::-webkit-scrollbar {
width: 6px !important;
height: 6px !important;
}
&::-webkit-scrollbar-track {
background: transparent !important;
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar) !important;
border: none !important;
border-radius: 3px !important;
background-clip: padding-box !important;
}
}
</style>

View file

@ -1,192 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li
ref="containerElement"
class="app-menu-entry"
:class="{
'app-menu-entry--active': app.active,
'app-menu-entry--truncated': needsSpace,
}">
<a
class="app-menu-entry__link"
:href="app.href"
:title="app.name"
:aria-current="app.active ? 'page' : false"
:target="app.target ? '_blank' : undefined"
:rel="app.target ? 'noopener noreferrer' : undefined">
<AppMenuIcon class="app-menu-entry__icon" :app="app" />
<span ref="labelElement" class="app-menu-entry__label">
{{ app.name }}
</span>
</a>
</li>
</template>
<script setup lang="ts">
import type { INavigationEntry } from '../types/navigation.d.ts'
import { onMounted, ref, watch } from 'vue'
import AppMenuIcon from './AppMenuIcon.vue'
const props = defineProps<{
app: INavigationEntry
}>()
const containerElement = ref<HTMLLIElement>()
const labelElement = ref<HTMLSpanElement>()
const needsSpace = ref(false)
/** Update the space requirements of the app label */
function calculateSize() {
const maxWidth = containerElement.value!.clientWidth
// Also keep the 0.5px letter spacing in mind
needsSpace.value = (maxWidth - props.app.name.length * 0.5) < (labelElement.value!.scrollWidth)
}
// Update size on mounted and when the app name changes
onMounted(calculateSize)
watch(() => props.app.name, calculateSize)
</script>
<style scoped lang="scss">
.app-menu-entry {
--app-menu-entry-font-size: 12px;
width: var(--header-height);
height: var(--header-height);
position: relative;
&__link {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
// Set color as this is shown directly on the background
color: var(--color-background-plain-text);
// Make space for focus-visible outline
width: calc(100% - 4px);
height: calc(100% - 4px);
margin: 2px;
}
&__label {
opacity: 0;
position: absolute;
font-size: var(--app-menu-entry-font-size);
// this is shown directly on the background
color: var(--color-background-plain-text);
text-align: center;
bottom: 0;
inset-inline-start: 50%;
top: 50%;
display: block;
transform: translateX(-50%);
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
letter-spacing: -0.5px;
}
body[dir=rtl] &__label {
transform: translateX(50%) !important;
}
&__icon {
font-size: var(--app-menu-entry-font-size);
}
&--active {
// When hover or focus, show the label and make it bolder than the other entries
.app-menu-entry__label {
font-weight: bolder;
}
// When active show a line below the entry as an "active" indicator
&::before {
content: " ";
position: absolute;
pointer-events: none;
border-bottom-color: var(--color-main-background);
transform: translateX(-50%);
width: 10px;
height: 5px;
border-radius: 3px;
background-color: var(--color-background-plain-text);
inset-inline-start: 50%;
bottom: 8px;
display: block;
transition: all var(--animation-quick) ease-in-out;
opacity: 1;
}
body[dir=rtl] &::before {
transform: translateX(50%) !important;
}
}
&__icon,
&__label {
transition: all var(--animation-quick) ease-in-out;
}
// Make the hovered entry bold to see that it is hovered
&:hover .app-menu-entry__label,
&:focus-within .app-menu-entry__label {
font-weight: bold;
}
// Adjust the width when an entry is focussed
// The focussed / hovered entry should grow, while both neighbors need to shrink
&--truncated:hover,
&--truncated:focus-within {
.app-menu-entry__label {
max-width: calc(var(--header-height) + var(--app-menu-entry-growth));
}
// The next entry needs to shrink half the growth
+ .app-menu-entry {
.app-menu-entry__label {
font-weight: normal;
max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
}
}
}
// The previous entry needs to shrink half the growth
&:has(+ .app-menu-entry--truncated:hover),
&:has(+ .app-menu-entry--truncated:focus-within) {
.app-menu-entry__label {
font-weight: normal;
max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
}
}
}
</style>
<style lang="scss">
// Showing the label
.app-menu-entry:hover,
.app-menu-entry:focus-within,
.app-menu__list:hover,
.app-menu__list:focus-within {
// Move icon up so that the name does not overflow the icon
.app-menu-entry__icon {
margin-block-end: 1lh;
}
// Make the label visible
.app-menu-entry__label {
opacity: 1;
}
// Hide indicator when the text is shown
.app-menu-entry--active::before {
opacity: 0;
}
.app-menu-icon__unread {
opacity: 0;
}
}
</style>

View file

@ -1,68 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<span
class="app-menu-icon"
role="img"
:aria-hidden="ariaHidden"
:aria-label="ariaLabel">
<img class="app-menu-icon__icon" :src="app.icon" alt="">
<IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" />
</span>
</template>
<script setup lang="ts">
import type { INavigationEntry } from '../types/navigation.ts'
import { n } from '@nextcloud/l10n'
import { computed } from 'vue'
import IconDot from 'vue-material-design-icons/CircleOutline.vue'
const props = defineProps<{
app: INavigationEntry
}>()
// only hide if there are no unread notifications
const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined)
const ariaLabel = computed(() => {
if (!props.app.unread) {
return undefined
}
return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})`
})
</script>
<style scoped lang="scss">
$icon-size: 20px;
$unread-indicator-size: 10px;
.app-menu-icon {
box-sizing: border-box;
position: relative;
height: $icon-size;
width: $icon-size;
&__icon {
transition: margin 0.1s ease-in-out;
height: $icon-size;
width: $icon-size;
filter: var(--background-image-invert-if-bright);
mask: var(--header-menu-icon-mask);
}
&__unread {
color: var(--color-text-error);
position: absolute;
// Align the dot to the top right corner of the icon
inset-block-end: calc($icon-size + ($unread-indicator-size / -2));
inset-inline-end: calc($unread-indicator-size / -2);
transition: all 0.1s ease-in-out;
}
}
</style>

View file

@ -3,11 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INavigationEntry } from './types/navigation.d.ts'
declare module '@nextcloud/event-bus' {
export interface NextcloudEvents {
// mapping of 'event name' => 'event type'
'nextcloud:unified-search:reset': undefined
'nextcloud:unified-search:search': { query: string }
'nextcloud:app-menu.refresh': { apps: INavigationEntry[] }
}
}

View file

@ -0,0 +1,46 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INavigationEntry } from '../../types/navigation.d.ts'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
// Mock l10n for deterministic output; mirror real n() plural behavior.
vi.mock('@nextcloud/l10n', () => ({
t: (_app: string, text: string) => text,
n: (_app: string, singular: string, plural: string, count: number, vars?: Record<string, unknown>) => {
const template = count === 1 ? singular : plural
return template.replace(/\{count\}/g, String(vars?.count ?? count))
},
}))
import AppItem from '../../components/AppItem.vue'
function makeApp(overrides: Partial<INavigationEntry> = {}): INavigationEntry {
return {
id: 'files',
active: false,
order: 0,
href: '/apps/files',
icon: '/apps/files/img/app.svg',
type: 'link',
name: 'Files',
unread: 0,
...overrides,
}
}
describe('core: AppItem', () => {
it('renders the label', () => {
const wrapper = mount(AppItem, { propsData: { app: makeApp({ name: 'Files' }) } })
expect(wrapper.text()).toContain('Files')
})
it('active app has aria-current="page"', () => {
const wrapper = mount(AppItem, { propsData: { app: makeApp({ active: true }) } })
expect(wrapper.attributes('aria-current')).toBe('page')
})
})

View file

@ -0,0 +1,168 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INavigationEntry } from '../../types/navigation.d.ts'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Hoisted so mocks exist before the SFC's imports run.
const initialState = vi.hoisted(() => ({
loadState: vi.fn(),
}))
vi.mock('@nextcloud/initial-state', () => initialState)
const auth = vi.hoisted(() => ({
getCurrentUser: vi.fn(() => ({ isAdmin: false })),
}))
vi.mock('@nextcloud/auth', () => auth)
const eventBus = vi.hoisted(() => {
const handlers: Record<string, Array<(payload: unknown) => void>> = {}
return {
subscribe: vi.fn((name: string, fn: (payload: unknown) => void) => {
(handlers[name] ||= []).push(fn)
}),
unsubscribe: vi.fn((name: string, fn: (payload: unknown) => void) => {
handlers[name] = (handlers[name] ?? []).filter((h) => h !== fn)
}),
emit: vi.fn((name: string, payload: unknown) => {
(handlers[name] ?? []).forEach((h) => h(payload))
}),
__handlers: handlers,
}
})
vi.mock('@nextcloud/event-bus', () => eventBus)
// Stub @nextcloud/router so we don't need a webroot for the moreApps URL.
vi.mock('@nextcloud/router', () => ({
generateUrl: (path: string) => path,
generateFilePath: (app: string, type: string, file: string) => `/apps/${app}/${type}/${file}`,
}))
// Build a minimal nav entry that satisfies INavigationEntry.
function makeApp(overrides: Partial<INavigationEntry> = {}): INavigationEntry {
return {
id: 'files',
active: false,
order: 0,
href: '/apps/files',
icon: '/apps/files/img/app.svg',
type: 'link',
name: 'Files',
unread: 0,
...overrides,
}
}
function fakeApps(): INavigationEntry[] {
return [
makeApp({ id: 'files', name: 'Files', href: '/apps/files', active: true }),
makeApp({ id: 'mail', name: 'Mail', href: '/apps/mail' }),
makeApp({ id: 'calendar', name: 'Calendar', href: '/apps/calendar' }),
]
}
function eightApps(activeIndex: number = -1): INavigationEntry[] {
const ids = ['files', 'mail', 'calendar', 'contacts', 'notes', 'photos', 'talk', 'deck']
return ids.map((id, i) => makeApp({
id,
name: id.charAt(0).toUpperCase() + id.slice(1),
href: `/apps/${id}`,
active: i === activeIndex,
}))
}
// Import AFTER mocks are registered. Static `import` would hoist above
// vi.mock() and break the wiring; dynamic import in beforeAll/await is the
// idiomatic Vitest workaround when you need to control mock state per test.
import type AppMenuModule from '../../components/AppMenu.vue'
let AppMenu: typeof AppMenuModule
beforeEach(async () => {
vi.clearAllMocks()
for (const k of Object.keys(eventBus.__handlers)) {
delete eventBus.__handlers[k]
}
initialState.loadState.mockImplementation((_app: string, key: string, fallback: unknown) => key === 'apps' ? fakeApps() : fallback)
auth.getCurrentUser.mockReturnValue({ isAdmin: false })
AppMenu = (await import('../../components/AppMenu.vue')).default
})
afterEach(() => {
// NcPopover teleports to <body>; clear teleported nodes between tests.
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild)
}
})
// Click the waffle trigger and poll until the teleported menuitems are in the
// DOM. NcPopover teleports to <body> so wrapper.find() can't see them; vi.waitFor
// retries the DOM query rather than relying on flaky nextTick/setTimeout flushes.
async function openPopover(wrapper: ReturnType<typeof mount>) {
await wrapper.get('.app-menu__waffle').trigger('click')
await vi.waitFor(() => {
expect(document.querySelectorAll('[role="menuitem"]').length).toBeGreaterThan(0)
})
}
describe('core: AppMenu', () => {
it('renders one AppItem per app in the list, plus the "App store" tile for non-admins', async () => {
const wrapper = mount(AppMenu, { attachTo: document.body })
await openPopover(wrapper)
const items = document.querySelectorAll('[role="menuitem"]')
expect(items).toHaveLength(4)
const labels = Array.from(items).map((el) => el.querySelector('.app-item__label')?.textContent?.trim() ?? '')
expect(labels).toEqual(['Files', 'Mail', 'Calendar', 'App store'])
})
it('renders the "More apps" tile when the current user is an admin', async () => {
auth.getCurrentUser.mockReturnValue({ isAdmin: true })
const wrapper = mount(AppMenu, { attachTo: document.body })
await openPopover(wrapper)
const items = document.querySelectorAll('[role="menuitem"]')
expect(items).toHaveLength(4)
const moreApps = Array.from(items).find((el) => el.textContent?.includes('More apps'))
expect(moreApps).toBeTruthy()
})
it('ArrowRight moves the roving stop from index 0 to index 1 and focuses it', async () => {
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => key === 'apps' ? eightApps() : fallback)
const wrapper = mount(AppMenu, { attachTo: document.body })
await openPopover(wrapper)
const grid = document.querySelector('.app-menu__grid') as HTMLElement | null
if (!grid) {
throw new Error('app-menu__grid not in document')
}
grid.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
}))
await wrapper.vm.$nextTick()
// One extra tick: the handler awaits $nextTick before calling
// .focus(), so we need a second flush before activeElement settles.
await wrapper.vm.$nextTick()
const items = document.querySelectorAll('[role="menuitem"]')
expect(items[1].getAttribute('tabindex')).toBe('0')
expect(items[0].getAttribute('tabindex')).toBe('-1')
expect(document.activeElement).toBe(items[1])
})
it('returnFocusTarget points at the trigger that opened the popover', async () => {
// focus-trap doesn't activate in jsdom (needs layout), so we can't assert
// on document.activeElement. Instead we call returnFocusTarget() directly
// (the same method NcPopover calls on deactivation).
const wrapper = mount(AppMenu, { attachTo: document.body })
await wrapper.get('.app-menu__current-app').trigger('click')
const currentApp = wrapper.get('.app-menu__current-app').element
expect(wrapper.vm.returnFocusTarget()).toBe(currentApp)
})
})

View file

@ -0,0 +1,58 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState, getNextcloudHeader } from '../../support/commonUtils.ts'
const getAppMenu = () => getNextcloudHeader().find('.app-menu')
// Both triggers share aria-label="Open apps menu", so getByRole can't
// disambiguate them. BEM classes owned by the component under test are
// the next-best stable selectors.
const getWaffleTrigger = () => getAppMenu().find('.app-menu__waffle')
describe('Header: App menu (waffle launcher)', { testIsolation: true }, () => {
beforeEach(() => {
clearState()
})
describe('Open and click', () => {
beforeEach(() => {
cy.createRandomUser().then(($user) => {
cy.login($user)
cy.visit('/')
})
})
it('opens the popover and navigates when a tile is clicked', () => {
getWaffleTrigger().click()
cy.get('.app-menu__popover').should('be.visible')
getWaffleTrigger().should('have.attr', 'aria-expanded', 'true')
cy.findAllByRole('menuitem').first()
.should('be.visible')
.then(($tile) => {
const href = $tile.attr('href')
expect(href).to.match(/\/apps\//)
cy.wrap($tile).click()
cy.location('pathname').should('include', '/apps/')
})
})
})
describe('Admin gating: "More apps" tile', () => {
const admin = new User('admin', 'admin')
beforeEach(() => {
cy.login(admin)
cy.visit('/')
})
it('shows the "More apps" tile for admins', () => {
getWaffleTrigger().click()
cy.get('.app-menu__popover').should('be.visible')
cy.findByRole('menuitem', { name: 'More apps' }).should('be.visible')
})
})
})

View file

@ -175,9 +175,15 @@ describe('Remove the default background with a bright color', function() {
})
it('See the header being inverted', function() {
// Probe the Nextcloud logo: it carries the same
// `var(--background-image-invert-if-bright)` filter and is always
// present in the header. The waffle launcher's current-app icon only
// renders when an app is active, which isn't the case on settings,
// and the in-popover tiles use a fixed brightness/invert filter
// regardless of theme so they're not a valid inversion probe.
cy.waitUntil(() => navigationHeader
.getNavigationEntries()
.find('img')
.logo()
.find('.logo')
.then((el) => {
let ret = true
el.each(function() {

View file

@ -35,9 +35,14 @@ describe('User theming set app order', () => {
const appOrder = ['Dashboard', 'Files']
appOrderList.assertAppOrder(appOrder)
// Check the top app menu order
navigationHeader.getNavigationEntries()
.each((entry, index) => expect(entry).contain.text(appOrder[index]!))
// Check the top app menu order. The launcher grid appends a synthetic
// "More apps" / "App store" tile to the user's apps, so iterate
// positionally only over the real-app prefix.
navigationHeader.getNavigationEntries().then(($entries) => {
appOrder.forEach((name, index) => {
expect($entries.eq(index)).to.contain.text(name)
})
})
})
it('Change the app order', () => {
@ -59,9 +64,14 @@ describe('User theming set app order', () => {
.scrollIntoView()
appOrderList.assertAppOrder(appOrder)
// Check the top app menu order
navigationHeader.getNavigationEntries()
.each((entry, index) => expect(entry).contain.text(appOrder[index]!))
// Check the top app menu order. Idempotent open in the page object
// re-opens the popover after the reload above. The synthetic trailing
// tile is ignored by iterating only over the expected app names.
navigationHeader.getNavigationEntries().then(($entries) => {
appOrder.forEach((name, index) => {
expect($entries.eq(index)).to.contain.text(name)
})
})
})
})
@ -140,9 +150,13 @@ describe('User theming set app order with default app', () => {
cy.reload()
const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2']
// Check the top app menu order
navigationHeader.getNavigationEntries()
.each((entry, index) => expect(entry).contain.text(appOrder[index]!))
// Check the top app menu order. See note above: the launcher appends
// a synthetic tile that we skip by iterating positionally.
navigationHeader.getNavigationEntries().then(($entries) => {
appOrder.forEach((name, index) => {
expect($entries.eq(index)).to.contain.text(name)
})
})
})
})
@ -219,9 +233,12 @@ describe('User theming reset app order', () => {
const appOrder = ['Dashboard', 'Files']
appOrderList.assertAppOrder(appOrder)
// Check the top app menu order
navigationHeader.getNavigationEntries()
.each((entry, index) => expect(entry).contain.text(appOrder[index]!))
// Check the top app menu order. See note above on the synthetic tile.
navigationHeader.getNavigationEntries().then(($entries) => {
appOrder.forEach((name, index) => {
expect($entries.eq(index)).to.contain.text(name)
})
})
})
it('See the reset button is disabled', () => {
@ -263,9 +280,12 @@ describe('User theming reset app order', () => {
it('See the app order is restored', () => {
const appOrder = ['Dashboard', 'Files']
appOrderList.assertAppOrder(appOrder)
// Check the top app menu order
navigationHeader.getNavigationEntries()
.each((entry, index) => expect(entry).contain.text(appOrder[index]!))
// Check the top app menu order. See note above on the synthetic tile.
navigationHeader.getNavigationEntries().then(($entries) => {
appOrder.forEach((name, index) => {
expect($entries.eq(index)).to.contain.text(name)
})
})
})
it('See the reset button is disabled again', () => {

View file

@ -131,7 +131,13 @@ describe('User select a bright custom color and remove background', function() {
})
it('See the header being inverted', function() {
cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => {
// Probe the Nextcloud logo: it carries the same
// `var(--background-image-invert-if-bright)` filter and is always
// present in the header. The waffle launcher's current-app icon only
// renders when an app is active, which isn't the case on settings,
// and the in-popover tiles use a fixed brightness/invert filter
// regardless of theme so they're not a valid inversion probe.
cy.waitUntil(() => navigationHeader.logo().find('.logo').then((el) => {
let ret = true
el.each(function() {
ret = ret && window.getComputedStyle(this).filter === 'invert(1)'
@ -157,7 +163,9 @@ describe('User select a bright custom color and remove background', function() {
})
it('See the header NOT being inverted this time', function() {
cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => {
// Probe the Nextcloud logo: see the inverted-header test above for
// why we don't probe the menu icons.
cy.waitUntil(() => navigationHeader.logo().find('.logo').then((el) => {
let ret = true
el.each(function() {
ret = ret && window.getComputedStyle(this).filter === 'none'

View file

@ -4,7 +4,11 @@
*/
/**
* Page object model for the Nextcloud navigation header
* Page object model for the Nextcloud navigation header.
*
* The app launcher (waffle menu) is an NcPopover whose content is teleported
* to <body>, so the menu items do not live inside the <nav> element. Selectors
* for the menu entries scope to the popover rather than the nav.
*/
export class NavigationHeader {
/**
@ -23,35 +27,91 @@ export class NavigationHeader {
}
/**
* Locator of the app navigation bar
* Locator of the app navigation bar.
*
* The accessible name is just "Applications" since the waffle redesign;
* the previous label "Applications menu" is gone.
*/
navigation() {
return this.header()
.findByRole('navigation', { name: 'Applications menu' })
.findByRole('navigation', { name: 'Applications' })
}
/**
* The toggle for the navigation overflow menu
* Open the waffle launcher popover.
*
* Idempotent: if the popover is already open the click is skipped, so
* callers can invoke this defensively at the start of any helper that
* needs the menu items in the DOM.
*/
openMenu() {
this.navigation()
.find('.app-menu__waffle')
.then(($trigger) => {
if ($trigger.attr('aria-expanded') !== 'true') {
cy.wrap($trigger).click()
}
})
// Popover is teleported to <body>, so query from the document root.
cy.get('.app-menu__popover').should('be.visible')
return this.popover()
}
/**
* Close the waffle launcher popover.
*
* Sends Escape rather than clicking outside: NcPopover's focus trap is
* active while the menu is open, so a stray click can land on a tile.
*/
closeMenu() {
cy.get('body').type('{esc}')
cy.get('.app-menu__popover').should('not.exist')
}
/**
* Locator for the popover content (the teleported grid wrapper).
*
* Scoping menu-item queries here is mandatory: the popover is rendered
* outside the <nav>, so `.within(navigation())` would find nothing.
*/
popover() {
return cy.get('[role="menu"][aria-label="Apps"]')
}
/**
* The waffle trigger that toggles the launcher.
*
* @deprecated The old "overflow" affordance is gone; this now points at
* the waffle button so existing call sites keep compiling. Prefer
* {@link openMenu} / {@link closeMenu} in new code.
*/
overflowNavigationToggle() {
return this.navigation()
.find('.app-menu__waffle')
}
/**
* Get all navigation entries
* Get all navigation entries in the launcher.
*
* Opens the popover first if it is not already open; the entries do not
* exist in the DOM otherwise. Each entry is rendered as an `<a role="menuitem">`.
*/
getNavigationEntries() {
return this.navigation()
.findAllByRole('listitem')
this.openMenu()
return this.popover().findAllByRole('menuitem')
}
/**
* Get the navigation entry for a given app
* Get the navigation entry for a given app.
*
* Each tile's accessible name comes from the `<a title="...">` attribute
* and the inner `.app-item__label`, so `findByRole('menuitem', { name })`
* matches reliably.
*
* @param name The app name
*/
getNavigationEntry(name: string) {
return this.navigation()
.findByRole('listitem', { name })
this.openMenu()
return this.popover().findByRole('menuitem', { name })
}
}

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long