mirror of
https://github.com/nextcloud/server.git
synced 2026-05-19 16:39:59 -04:00
Merge pull request #60180 from nextcloud/feat/59888/waffle-menu
feat(core): app menu waffle launcher
This commit is contained in:
commit
f501b442a4
19 changed files with 954 additions and 382 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
174
core/src/components/AppItem.vue
Normal file
174
core/src/components/AppItem.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
3
core/src/eventbus.d.ts
vendored
3
core/src/eventbus.d.ts
vendored
|
|
@ -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[] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
46
core/src/tests/components/AppItem.spec.ts
Normal file
46
core/src/tests/components/AppItem.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
168
core/src/tests/components/AppMenu.spec.ts
Normal file
168
core/src/tests/components/AppMenu.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
58
cypress/e2e/core/header_app-menu.cy.ts
Normal file
58
cypress/e2e/core/header_app-menu.cy.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
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