Merge pull request #36232 from nextcloud/enh/a11y-user-menu

Port user menu to Vue
pull/36673/head
Pytal 2023-02-10 19:22:45 +07:00 committed by GitHub
commit b59b8fd808
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 344 additions and 261 deletions

@ -13,7 +13,7 @@
<bugs>https://github.com/nextcloud/server</bugs>
<navigations>
<navigation>
<id>user_status-menuitem</id>
<id>user_status-menu-entry</id>
<name>User status</name>
<order>1</order>
<type>settings</type>

@ -26,7 +26,7 @@
<a v-if="!inline"
class="user-status-menu-item__header"
:href="profilePageLink"
@click="loadProfilePage">
@click.exact="loadProfilePage">
<div class="user-status-menu-item__header-content">
<div class="user-status-menu-item__header-content-displayname">{{ displayName }}</div>
<div v-if="!loadingProfilePage" class="user-status-menu-item__header-content-placeholder" />
@ -234,11 +234,6 @@ export default {
align-items: flex-start !important;
color: var(--color-main-text) !important;
&:focus-visible {
padding: 6px 8px 1px 8px !important;
margin: 2px !important;
}
&:not([href]) {
height: var(--header-menu-item-height) !important;
color: var(--color-text-maxcontrast) !important;

@ -24,10 +24,11 @@
import Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth'
import UserStatus from './UserStatus'
import store from './store'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar'
import { loadState } from '@nextcloud/initial-state'
import { subscribe } from '@nextcloud/event-bus'
import UserStatus from './UserStatus.vue'
import store from './store/index.js'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
@ -35,31 +36,23 @@ __webpack_nonce__ = btoa(getRequestToken())
Vue.prototype.t = t
Vue.prototype.$t = t
const avatarDiv = document.getElementById('avatardiv-menu')
const userStatusData = loadState('user_status', 'status')
const propsData = {
preloadedUserStatus: {
message: userStatusData.message,
icon: userStatusData.icon,
status: userStatusData.status,
},
user: avatarDiv.dataset.user,
displayName: avatarDiv.dataset.displayname,
disableMenu: true,
disableTooltip: true,
}
const mountPoint = document.getElementById('user_status-menu-entry')
const NcAvatarInMenu = Vue.extend(NcAvatar)
new NcAvatarInMenu({ propsData }).$mount('#avatardiv-menu')
const mountMenuEntry = () => {
const mountPoint = document.getElementById('user_status-menu-entry')
// eslint-disable-next-line no-new
new Vue({
el: mountPoint,
render: h => h(UserStatus),
store,
})
}
// Register settings menu entry
export default new Vue({
el: 'li[data-id="user_status-menuitem"]',
// eslint-disable-next-line vue/match-component-file-name
name: 'UserStatusRoot',
render: h => h(UserStatus),
store,
})
if (mountPoint) {
mountMenuEntry()
} else {
subscribe('core:user-menu:mounted', mountMenuEntry)
}
// Register dashboard status
document.addEventListener('DOMContentLoaded', function() {

File diff suppressed because one or more lines are too long

@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["header.scss","variables.scss"],"names":[],"mappings":"AAiBA,mBAEC,yBACA,sBACA,qBACA,6PACC,aAGD,+QACC,YACA,kBACA,2BACA,WACA,WACA,kBACA,2CACA,SACA,UAGD,gLACC,WAIA,kPACC,WAGD,+HACC,SAOH,+DAGC,oBACA,kBACA,MACA,WACA,aACA,OC2Ce,KD1Cf,sBACA,8BAID,WACC,cACA,kBACA,kBACA,wBACA,sBACA,UACA,mBACA,aACA,eACA,gBACA,WAEA,mCACC,UAaD,gCACC,8CACA,sDACA,yCACA,sBACA,aACA,kBACA,gBAfD,gBACA,oCAgBC,UACA,SACA,SACA,gBAEA,kDACC,aAID,sCACC,gCACA,iDACA,YACA,YACA,SACA,QACA,kBACA,oBACA,WAGD,uEAEC,iCAzCF,gBACA,oCA6CC,iDACC,YACA,aACA,sBACA,QAEC,sDACC,kBACA,oBACA,mBACA,OAlDuB,KAmDvB,6BACA,kBACA,sBACA,mBACA,kBACA,WACA,wHAEC,+CAED,0HAEC,4CAED,oEACC,gDACA,aAED,2DACC,qBACA,iBACA,6BACA,mBACA,gBACA,uBACA,gBAED,0EACC,kBACA,0BAED,oHAEC,WACA,kBACA,YACA,WACA,wCAML,cACC,oBACA,yFACA,4BACA,wBACA,2BACA,WACA,kBACA,UACA,QACA,WAEA,gFAGD,kCACC,aACA,mBACA,cAGD,sFAEC,oBACA,mBAGD,0CACC,SACA,mBACA,YAGD,4CACC,yBACA,cAKA,qDAEC,YACA,kBACA,6EACC,aACA,uBACA,mBACA,MClIY,KDmIZ,YACA,eACA,YACA,UACA,aAEA,yFACC,UAGD,yGACC,aASL,0CACC,YAKD,gBACC,gCACA,eACA,iBACA,SACA,UACA,kBACA,gBACA,uBAEA,cAGD,kBACC,gCACA,kBACA,gBACA,eACA,iBACA,gBACA,uBAID,UACC,qBACA,YACA,eACA,cAGA,kBACC,UACA,kBAEA,yEAGC,gCAEA,4OAEC,kBACA,2CACA,YAED,0GACC,2CAED,kIACC,UAKF,6BACC,eACA,YACA,WAEA,iCACC,UACA,eAGD,gEACC,aAIF,qCACC,YACA,WACA,eAGA,oDACC,UAKF,wCACC,YACA,SACA,QACA,kBACA,oBACA,WACA,YACA,cACA,WACA,WACA,kBACA,2CAIF,2BACC,WAKF,cACC,kBACA,gBACA,aACA,WACA,SACA,YACA,aAEA,2BACC,IC/Qc,KDuRf,gDACC,mBACA,eAED,gJAEC,qBACA,YACA","file":"header.css"}
{"version":3,"sourceRoot":"","sources":["header.scss","variables.scss"],"names":[],"mappings":"AAiBA,mBAEC,yBACA,sBACA,qBACA,6PACC,aAGD,+QACC,YACA,kBACA,2BACA,WACA,WACA,kBACA,2CACA,SACA,UAGD,gLACC,WAIA,kPACC,WAGD,+HACC,SAOH,+DAGC,oBACA,kBACA,MACA,WACA,aACA,OC2Ce,KD1Cf,sBACA,8BAID,WACC,cACA,kBACA,kBACA,wBACA,sBACA,UACA,mBACA,aACA,eACA,gBACA,WAEA,mCACC,UAaD,gCACC,8CACA,sDACA,yCACA,sBACA,aACA,kBACA,gBAfD,gBACA,oCAgBC,UACA,SACA,SACA,gBAEA,kDACC,aAID,sCACC,gCACA,iDACA,YACA,YACA,SACA,QACA,kBACA,oBACA,WAGD,uEAEC,iCAzCF,gBACA,oCA4CA,cACC,oBACA,yFACA,4BACA,wBACA,2BACA,WACA,kBACA,UACA,QACA,WAEA,gFAGD,kCACC,aACA,mBACA,cAGD,sFAEC,oBACA,mBAGD,0CACC,SACA,mBACA,YAGD,4CACC,yBACA,cAKA,qDAEC,YACA,kBACA,6EACC,aACA,uBACA,mBACA,MC3EY,KD4EZ,YACA,eACA,YACA,UACA,aAEA,yFACC,UAGD,yGACC,aASL,0CACC,YAKD,gBACC,gCACA,eACA,iBACA,SACA,UACA,kBACA,gBACA,uBAEA,cAGD,kBACC,gCACA,kBACA,gBACA,eACA,iBACA,gBACA,uBAID,cACC,kBACA,gBACA,aACA,WACA,SACA,YACA,aAEA,2BACC,ICxIc,KD+If,gDACC,mBACA,eAED,gJAEC,qBACA,YACA","file":"header.css"}

@ -130,61 +130,6 @@
-webkit-overflow-scrolling: touch;
@include header-menu-height();
}
/* Use by the settings right menu */
&.settings-menu > ul {
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
li {
a {
border-radius: 6px;
display: inline-flex;
align-items: center;
height: $header-menu-entry-height;
color: var(--color-main-text);
padding: 10px 12px;
box-sizing: border-box;
white-space: nowrap;
position: relative;
width: 100%;
&:hover,
&:focus {
background-color: var(--color-background-hover);
}
&:active,
&.active {
background-color: var(--color-primary-light);
}
&:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-primary);
outline: none;
}
span {
display: inline-block;
padding-bottom: 0;
color: var(--color-main-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 110px;
}
.icon-loading-small {
margin-right: 10px;
background-size: 16px 16px;
}
img,
svg {
opacity: .7;
margin-right: 10px;
height: 16px;
width: 16px;
filter: var(--background-invert-if-dark);
}
}
}
}
}
.logo {
display: inline-flex;
@ -284,86 +229,6 @@
text-overflow: ellipsis;
}
/* USER MENU -----------------------------------------------------------------*/
#settings {
display: inline-block;
height: 100%;
cursor: pointer;
flex: 0 0 auto;
/* User menu on the right */
#expand {
opacity: 1; /* override icon opacity */
margin-right: 12px;
&:hover,
&:focus,
&:active {
color: var(--color-primary-text);
#expandDisplayName,
.avatardiv{
border-radius: 50%;
border: 2px solid var(--color-primary-text);
margin: -2px;
}
.avatardiv{
background-color: var(--color-primary-text);
}
#expandDisplayName {
opacity: 1;
}
}
/* Profile picture in header */
.avatardiv {
cursor: pointer;
height: 32px;
width: 32px;
img {
opacity: 1;
cursor: pointer;
}
/* do not show display name when profile picture is present */
&.avatardiv-shown + #expandDisplayName {
display: none;
}
}
#expandDisplayName {
padding: 8px;
opacity: .6;
cursor: pointer;
/* full opacity for gear icon if active */
#body-settings & {
opacity: 1;
}
}
/* show triangle below user menu if active */
#body-settings &:before {
content: ' ';
height: 0;
width: 0;
position: absolute;
pointer-events: none;
bottom: 2px;
z-index: 100;
display: block;
width: 10px;
height: 5px;
border-radius: 3px;
background-color: var(--color-primary-text);
}
}
#expanddiv:after {
right: 22px;
}
}
/* Skip navigation links show only on keyboard focus */
#skip-actions {
position: absolute;
@ -379,7 +244,6 @@
}
}
/* Empty content messages in the header e.g. notifications, contacts menu, … */
header #emptycontent,
header .emptycontent {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -2,6 +2,7 @@
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
@ -20,41 +21,17 @@
*
*/
import OC from '../OC'
import Vue from 'vue'
import $ from 'jquery'
import UserMenu from '../views/UserMenu.vue'
export const setUp = () => {
const $menu = $('#header #settings')
// Using page terminoogy as below
const $excludedPageClasses = [
'user-status-menu-item__header',
]
// show loading feedback
$menu.delegate('a', 'click', event => {
let $page = $(event.target)
if (!$page.is('a')) {
$page = $page.closest('a')
}
if (event.which === 1 && !event.ctrlKey && !event.metaKey) {
if (!$excludedPageClasses.includes($page.attr('class'))) {
$page.find('img').remove()
$page.find('div').remove() // prevent odd double-clicks
$page.prepend($('<div></div>').addClass('icon-loading-small'))
}
} else {
// Close navigation when opening menu entry in
// a new tab
OC.hideMenus(() => false)
}
})
$menu.delegate('a', 'mouseup', event => {
if (event.which === 2) {
// Close navigation when opening app in
// a new tab via middle click
OC.hideMenus(() => false)
}
})
const mountPoint = document.getElementById('user-menu')
if (mountPoint) {
// eslint-disable-next-line no-new
new Vue({
el: mountPoint,
render: h => h(UserMenu),
})
}
}

@ -0,0 +1,106 @@
<!--
- @copyright 2023 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<li :id="id"
class="menu-entry">
<a v-if="href"
:href="href"
:class="{ active }"
@click.exact="handleClick">
<NcLoadingIcon v-if="loading"
class="menu-entry__loading-icon"
:size="18" />
<img v-else :src="cachedIcon" alt="" />
{{ name }}
</a>
<button v-else>
<img :src="cachedIcon" alt="" />
{{ name }}
</button>
</li>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
const versionHash = loadState('core', 'versionHash', '')
export default {
name: 'UserMenuEntry',
components: {
NcLoadingIcon,
},
props: {
id: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
href: {
type: String,
required: true,
},
active: {
type: Boolean,
required: true,
},
icon: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
}
},
computed: {
cachedIcon() {
return `${this.icon}?v=${versionHash}`
},
},
methods: {
handleClick() {
this.loading = true
},
},
}
</script>
<style lang="scss" scoped>
.menu-entry {
&__loading-icon {
margin-right: 8px;
}
}
</style>

@ -0,0 +1,184 @@
<!--
- @copyright 2023 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<NcHeaderMenu id="user-menu"
class="user-menu"
:aria-label="t('core', 'Open settings menu')">
<template #trigger>
<NcAvatar class="user-menu__avatar"
:disable-menu="true"
:disable-tooltip="true"
:user="userId" />
</template>
<nav class="user-menu__nav"
:aria-label="t('core', 'Settings menu')">
<ul>
<UserMenuEntry v-for="entry in settingsNavEntries"
v-bind="entry"
:key="entry.id" />
</ul>
</nav>
</NcHeaderMenu>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
import UserMenuEntry from '../components/UserMenu/UserMenuEntry.vue'
const settingsNavEntries = loadState('core', 'settingsNavEntries', [])
export default {
name: 'UserMenu',
components: {
NcAvatar,
NcHeaderMenu,
UserMenuEntry,
},
data() {
return {
settingsNavEntries,
userId: getCurrentUser()?.uid,
}
},
mounted() {
emit('core:user-menu:mounted')
},
}
</script>
<style lang="scss" scoped>
.user-menu {
margin-right: 12px;
&:deep {
.header-menu {
&__trigger {
opacity: 1 !important;
&:focus-visible {
.user-menu__avatar {
border: 2px solid var(--color-primary-text);
}
}
}
&__carret {
display: none !important;
}
&__content {
width: fit-content !important;
}
}
}
&__avatar {
&:active,
&:focus,
&:hover {
border: 2px solid var(--color-primary-text);
}
}
&__nav {
display: flex;
width: 100%;
ul {
display: flex;
flex-direction: column;
gap: 2px;
&:deep {
li {
a,
button {
border-radius: 6px;
display: inline-flex;
align-items: center;
height: var(--header-menu-item-height);
color: var(--color-main-text);
padding: 10px 8px;
box-sizing: border-box;
white-space: nowrap;
position: relative;
width: 100%;
&:hover {
background-color: var(--color-background-hover);
}
&:focus-visible {
background-color: var(--color-background-hover) !important;
box-shadow: inset 0 0 0 2px var(--color-primary) !important;
outline: none !important;
}
&:active,
&.active {
background-color: var(--color-primary-light);
}
span {
padding-bottom: 0;
color: var(--color-main-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 110px;
}
img {
width: 16px;
height: 16px;
margin-right: 10px;
}
img,
svg {
opacity: .7;
filter: var(--background-invert-if-dark);
}
}
// Override global button styles
button {
background-color: transparent;
border: none;
font-weight: normal;
margin: 0;
}
}
}
}
}
}
</style>

@ -70,45 +70,7 @@ p($theme->getTitle());
<div id="unified-search"></div>
<div id="notifications"></div>
<div id="contactsmenu"></div>
<div id="settings">
<div id="expand" tabindex="0" role="button" class="menutoggle"
aria-label="<?php p($l->t('Open settings menu'));?>"
aria-haspopup="true" aria-controls="expanddiv" aria-expanded="false">
<div id="avatardiv-menu" class="avatardiv<?php if ($_['userAvatarSet']) {
print_unescaped(' avatardiv-shown');
} else {
print_unescaped('" style="display: none');
} ?>"
data-user="<?php p($_['user_uid']); ?>"
data-displayname="<?php p($_['user_displayname']); ?>"
<?php
if ($_['userAvatarSet']) {
$avatar32 = $getUserAvatar(32); ?> data-avatar="<?php p($avatar32); ?>"
<?php
} ?>>
<?php
if ($_['userAvatarSet']) {?>
<img alt="" width="32" height="32"
src="<?php p($avatar32);?>"
srcset="<?php p($getUserAvatar(64));?> 2x, <?php p($getUserAvatar(128));?> 4x"
>
<?php } ?>
</div>
</div>
<nav class="settings-menu" id="expanddiv" style="display:none;">
<ul>
<?php foreach ($_['settingsnavigation'] as $entry):?>
<li data-id="<?php p($entry['id']); ?>">
<a href="<?php print_unescaped($entry['href'] !== '' ? $entry['href'] : '#'); ?>"
<?php if ($entry["active"]): ?> class="active"<?php endif; ?>>
<img alt="" src="<?php print_unescaped($entry['icon'] . '?v=' . $_['versionHash']); ?>">
<?php p($entry['name']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
</div>
<div id="user-menu"></div>
</div>
</header>

File diff suppressed because one or more lines are too long

@ -366,6 +366,8 @@ object-assign
/*! For license information please see NcEmojiPicker.js.LICENSE.txt */
/*! For license information please see NcLoadingIcon.js.LICENSE.txt */
/*! For license information please see NcModal.js.LICENSE.txt */
/*! For license information please see NcNoteCard.js.LICENSE.txt */

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -130,7 +130,7 @@ class TemplateLayout extends \OC_Template {
$navigation = $this->navigationManager->getAll();
$this->assign('navigation', $navigation);
$settingsNavigation = $this->navigationManager->getAll('settings');
$this->assign('settingsnavigation', $settingsNavigation);
$this->initialState->provideInitialState('core', 'settingsNavEntries', $settingsNavigation);
foreach ($navigation as $entry) {
if ($entry['active']) {
@ -268,7 +268,7 @@ class TemplateLayout extends \OC_Template {
$this->assign('cssfiles', []);
$this->assign('printcssfiles', []);
$this->assign('versionHash', self::$versionHash);
$this->initialState->provideInitialState('core', 'versionHash', self::$versionHash);
foreach ($cssFiles as $info) {
$web = $info[1];
$file = $info[2];

@ -32,7 +32,7 @@ class SettingsMenuContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function settingsSectionInHeader() {
return Locator::forThe()->xpath("//*[@id = 'header']//*[@id = 'settings']")->
return Locator::forThe()->xpath("//*[@id = 'header']//*[@id = 'user-menu']")->
describedAs("Settings menu section in the header");
}
@ -40,7 +40,7 @@ class SettingsMenuContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function settingsMenuButton() {
return Locator::forThe()->id("expand")->
return Locator::forThe()->css(".header-menu__trigger")->
descendantOf(self::settingsSectionInHeader())->
describedAs("Settings menu button");
}
@ -49,7 +49,7 @@ class SettingsMenuContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function settingsMenu() {
return Locator::forThe()->id("expanddiv")->
return Locator::forThe()->css(".user-menu__nav")->
descendantOf(self::settingsSectionInHeader())->
describedAs("Settings menu");
}