I'm implementing an accessible profile menu in Vue 3 using v-menu with role="dialog". When the menu opens, I'm setting focus on a specific element, but the screen reader (like NVDA) is announcing all content in the dialog instead of just the focused element.
<div class="d-sm-flex d-md-flex justify-end align-center menu-items">
<v-menu v-model="menu" :close-on-content-click="false" location="bottom"
class="profile-card" @update:modelValue="handleMenuToggle">
<template v-slot:activator="{ props }">
<v-btn color="indigo" v-bind="props" class="profile-btn" role="button" id="profile-menu-button"
@click="openProfileMenu" aria-labelledby="profileLabel userName userRoleText"
:aria-expanded="menu" aria-haspopup="dialog" ref="profileButton">
<img src="/images/user.svg" alt="" aria-hidden="true" />
<div class="d-none d-sm-flex flex-column align-start">
<span id="profileLabel" class="sr-only">{{ t('profile') }}</span>
<span class="font-16 font-700 color-white" id="userName">{{ store?.user?.name }}</span>
<span class="font-14 font-400 color-white" id="userRoleText">{{ userRole }}</span>
</div>
</v-btn>
</template>
<v-card min-width="380" role="dialog" aria-modal="true" aria-label="Profile Mega Menu" ref="menuCard"
@keydown.escape="closeProfileMenu">
<div class="header text-right pa-6 pr-8 py-0">
<button id="close-profile-menu" @click="closeProfileMenu" :aria-label="ariaLabelData.closeProfile"
class="cursor-pointer close-icon" ref="closeButton">
<img src="/images/close-icon.svg" alt="" aria-hidden="true" />
</button>
</div>
<div class="uesrname" aria-labelledby="profileLabelInner userNameInner userRoleTextInner"
tabindex="0">
<div class="user-profile">
<span id="profileLabelInner" class="sr-only">{{ t('profile') }}</span>
<p id="userNameInner" class="font-16 font-600 name">{{ store?.user?.fullName }}</p>
<p id="userRoleTextInner" class="font-12 font-400 role">{{ userRole }}</p>
</div>
</div>
<div class="profileMenu">
<div
@click="closeMenu"
:aria-label="ariaLabelData.myProfile"
tabindex="0"
id="my-profile-button"
ref="myProfileButton"
class="profileMenu__item">
<img src="/public/images/user-blue.svg" class="mr-4" alt="" aria-hidden="true" />
<span aria-hidden="true">{{ t('myProfile') }}</span>
</div>
<div
v-if="store?.user?.roles.includes('PARTNER_ADMIN') || store?.user?.roles.includes('ADMIN') || store?.user?.roles.includes('SUB_ADMIN')"
@click="redirectToDashboard"
class="profileMenu__item profileMenu__item--dashboard"
:aria-label="ariaLabelData.dashboard"
tabindex="0">
<div class="profileMenu__item-wrapper">
<div class="profileMenu__item-primary">
<img src="/public/images/dashboard.svg" class="mr-4" alt="" aria-hidden="true" />
<span aria-hidden="true">{{ t('dashboard') }}</span>
</div>
<div class="profileMenu__item-secondary">
<img src="/public/images/action-icon.svg" alt="" aria-hidden="true" />
</div>
</div>
</div>
<div
v-if="(store?.user?.roles.includes('PARTNER_ADMIN') || store?.user?.roles.includes('ADMIN')) && store?.user?.profileConfiguration?.enabled"
@click="redirectToProfile"
class="profileMenu__item profileMenu__item--profile"
:aria-label="ariaLabelData.profilePage"
tabindex="0">
<div class="profileMenu__item-wrapper">
<div class="profileMenu__item-primary">
<img src="/public/images/corporate-icon.svg" class="mr-4" alt="" aria-hidden="true" />
<span aria-hidden="true">{{ t('profilePage') }}</span>
</div>
<div class="profileMenu__item-secondary">
<img src="/public/images/action-icon.svg" alt="" aria-hidden="true" />
</div>
</div>
</div>
<div
@click="closeMenu"
class="profileMenu__item"
:aria-label="ariaLabelData.mfasettings"
tabindex="0">
<img src="/public/images/mfa-setting.svg" class="mr-4" alt="" aria-hidden="true" />
<span aria-hidden="true">{{ t('mfasettings') }}</span>
</div>
</div>
<v-card-actions>
<v-btn variant="text" @click="signout" :aria-label="ariaLabelData.signout" role="button"
tabindex="0">
{{ t('signout') }}
<img src="/public/images/arrow-blue-right.svg" class="ml-2" alt="" aria-hidden="true" />
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</div>
Expected behavior: When the dialog opens and focus is set on the my profile button, only that button should be announced by the screen reader.
Actual behavior: The screen reader announces all focusable elements within the dialog, even though focus is properly trapped and set on the my profile button.
What I've tried:
- Setting aria-modal="true"
- Using trapFocus() to contain focus within the dialog
- Setting explicit focus on the my profile button with a delay
Is there something specific about Vue's rendering or the way screen readers interact with dynamically created dialogs that I'm missing?